Update Healer Man server
This commit is contained in:
1 parent
39458d5de6
commit
6cc1b9217f
203 files changed
+133929
-31271
No files matched your search
@@ -0,0 +1,79 @@
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { BufferGeometry, Float32BufferAttribute, Matrix4, Vector3 } from "three";
|
||||
import { requireDungeonDefinition } from "../src/game/dungeonRegistry";
|
||||
import {
|
||||
buildPartyNavigationGraph,
|
||||
findNearestPartyNavigationSurface,
|
||||
findPartyNavigationPath,
|
||||
} from "../src/game/partyNavigation";
|
||||
|
||||
const dungeonIds = process.argv.slice(2).filter((argument) => !argument.startsWith("--"));
|
||||
if (!dungeonIds.length) throw new Error("Pass at least one dungeon id.");
|
||||
|
||||
async function navigationGraph(dungeonId: string) {
|
||||
const dungeon = requireDungeonDefinition(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();
|
||||
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 geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
|
||||
try {
|
||||
return buildPartyNavigationGraph(geometry);
|
||||
} finally {
|
||||
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 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}.`);
|
||||
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],
|
||||
);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
dungeonId,
|
||||
status: "navigation-guidance-only; live UI traversal required",
|
||||
objective: boss.name,
|
||||
entrance: start.position,
|
||||
objectivePosition: end.position,
|
||||
startSnapDistance: start.distance,
|
||||
objectiveSnapDistance: end.distance,
|
||||
waypointCount: path.length,
|
||||
expectedNavigationDistance: Number(expectedDistance.toFixed(2)),
|
||||
waypoints: path,
|
||||
})}\n`);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { abilitiesForClass, abilityTalentNodeId } from "../../src/game/abilityCatalog";
|
||||
import { abilityHasRotationSemantics, rotationProfileFor } from "../../src/game/combatRotation";
|
||||
import { BOSS_TESTING_CATALOG } from "../../src/game/bossTesting";
|
||||
import {
|
||||
coaClassDefinition,
|
||||
coaEntriesForClass,
|
||||
coaSpellById,
|
||||
COA_LIVE_SOURCE,
|
||||
} from "../../src/game/coaLiveCatalog";
|
||||
import {
|
||||
COA_FIRST_PARTY_MEMBERS,
|
||||
coaFirstPartySimulationConfig,
|
||||
loadCoaFirstPartyBuilds,
|
||||
} from "../../src/game/benchmark/coaFirstParty";
|
||||
import type { CoaClassId } from "../../src/app/characterCatalog";
|
||||
|
||||
const root = fileURLToPath(new URL("../../", import.meta.url));
|
||||
const args = process.argv.slice(2);
|
||||
let level = 60;
|
||||
let listDungeon: string | undefined;
|
||||
const bossKeys: string[] = [];
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const flag = args[index];
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${flag}.`);
|
||||
if (flag === "--level") level = Number(value);
|
||||
else if (flag === "--boss") bossKeys.push(value);
|
||||
else if (flag === "--list-bosses") listDungeon = value;
|
||||
else throw new Error(`Unknown argument ${flag}.`);
|
||||
}
|
||||
|
||||
if (listDungeon) {
|
||||
if (bossKeys.length) throw new Error("Use either --list-bosses or explicit --boss keys.");
|
||||
const bosses = BOSS_TESTING_CATALOG.filter((boss) => boss.dungeonId === listDungeon && boss.category === "wow");
|
||||
if (!bosses.length) throw new Error(`No WoW bosses found for ${listDungeon}.`);
|
||||
console.log(JSON.stringify(bosses.map(({ key, name }) => ({ key, name })), null, 2));
|
||||
} else {
|
||||
// An explicit boss list prevents preparation from becoming an all-boss run.
|
||||
const selectedBosses = bossKeys.map((key) => {
|
||||
const boss = BOSS_TESTING_CATALOG.find((candidate) => candidate.key === key && candidate.category === "wow");
|
||||
if (!boss) throw new Error(`Unknown WoW boss key: ${key}.`);
|
||||
return { key: boss.key, name: boss.name };
|
||||
});
|
||||
const config = coaFirstPartySimulationConfig(level, bossKeys);
|
||||
const builds = await loadCoaFirstPartyBuilds(level);
|
||||
const sourceBytes = await readFile(resolve(root, "src/game/coaLiveData.generated.json"));
|
||||
const sourceSha256 = createHash("sha256").update(sourceBytes).digest("hex");
|
||||
const classes = COA_FIRST_PARTY_MEMBERS.map((member, slot) => {
|
||||
const classId = member.classId as CoaClassId;
|
||||
const definition = coaClassDefinition(classId);
|
||||
const abilities = abilitiesForClass(classId);
|
||||
const build = builds[slot];
|
||||
const firstPassTabs = new Set(definition.tabs
|
||||
.filter((tab) => tab.tabName === "Class" || tab.tabName === member.specialization)
|
||||
.map((tab) => tab.tabId));
|
||||
const nodes = coaEntriesForClass(classId).map((entry) => {
|
||||
const nodeId = `coa-entry-${entry.id}`;
|
||||
const mapped = abilities.filter((ability) => abilityTalentNodeId(ability) === nodeId);
|
||||
const effects = mapped.flatMap((ability) => [
|
||||
...ability.effects,
|
||||
...(ability.ranks ?? []).flatMap((rank) => rank.effects),
|
||||
]);
|
||||
const spellIds = [...new Set(entry.rankDescriptions.map((rank) => rank.spellId))];
|
||||
return {
|
||||
nodeId,
|
||||
sourceNodeId: entry.id,
|
||||
name: entry.name,
|
||||
tree: definition.tabs.find((tab) => tab.tabId === entry.tabId)?.tabName ?? String(entry.tabId),
|
||||
phase: firstPassTabs.has(entry.tabId) ? "first-party" : "remaining-trees",
|
||||
description: entry.description,
|
||||
rankDescriptions: entry.rankDescriptions,
|
||||
spellIds,
|
||||
missingGlobalSpellIds: spellIds.filter((id) => !coaSpellById(id)?.dbc),
|
||||
selectedRank: build.talentRanks[nodeId] ?? 0,
|
||||
runtimeAbilityIds: mapped.map((ability) => ability.id),
|
||||
effectKinds: [...new Set(effects.map((effect) => effect.kind))].sort(),
|
||||
hasRotationSemantics: mapped.some(abilityHasRotationSemantics),
|
||||
sourceOperationsNeedingReview: effects.filter((effect) => (
|
||||
effect.kind === "scripted" || effect.kind === "unsupported"
|
||||
|| (effect.kind === "utility" && effect.action === "class-script")
|
||||
)),
|
||||
// A catalog match cannot establish working class mechanics.
|
||||
mechanicVerification: "needs-review",
|
||||
};
|
||||
});
|
||||
return {
|
||||
...member,
|
||||
slot,
|
||||
buildId: build.id,
|
||||
talentRanks: build.talentRanks,
|
||||
talentAllocation: "generated role-weighted diagnostic build; requires authored review",
|
||||
rotationProfileId: rotationProfileFor(classId, member.specializationId, member.specialization)?.id ?? null,
|
||||
readiness: "needs-testing",
|
||||
counts: {
|
||||
classNodes: nodes.length,
|
||||
firstPartyNodes: nodes.filter((node) => node.phase === "first-party").length,
|
||||
nodesWithoutRuntimeAbility: nodes.filter((node) => node.runtimeAbilityIds.length === 0).length,
|
||||
nodesWithRetainedSourceOperations: nodes.filter((node) => node.sourceOperationsNeedingReview.length > 0).length,
|
||||
sourceGapNodes: nodes.filter((node) => node.missingGlobalSpellIds.length > 0).length,
|
||||
},
|
||||
nodes,
|
||||
};
|
||||
});
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
milestone: "coa-first-party",
|
||||
level,
|
||||
source: {
|
||||
realm: COA_LIVE_SOURCE.realmName,
|
||||
capturedAt: COA_LIVE_SOURCE.synchronizedAt,
|
||||
catalogSha256: sourceSha256,
|
||||
spellDbcPath: COA_LIVE_SOURCE.coaSpellDbcPath,
|
||||
},
|
||||
scope: "Explicit five-class diagnostic; no readiness promotion or balance certification.",
|
||||
bosses: selectedBosses,
|
||||
classes,
|
||||
};
|
||||
const output = resolve(root, "artifacts/coa-first-party", `level-${level}`);
|
||||
await mkdir(output, { recursive: true });
|
||||
const saveJson = (name: string, value: unknown) => writeFile(resolve(output, name), `${JSON.stringify(value, null, 2)}\n`);
|
||||
await saveJson("audit.json", report);
|
||||
await saveJson("config.json", config);
|
||||
for (let slot = 0; slot < COA_FIRST_PARTY_MEMBERS.length; slot += 1) {
|
||||
await saveJson(`seat-${slot + 1}.json`, coaFirstPartySimulationConfig(level, bossKeys, slot));
|
||||
}
|
||||
const markdown = [
|
||||
"# First CoA party — mechanic review inventory",
|
||||
"",
|
||||
`Level ${level}; ${COA_LIVE_SOURCE.realmName} capture ${COA_LIVE_SOURCE.synchronizedAt}.`,
|
||||
"Catalog mappings below are review inventory, not implemented-mechanic counts.",
|
||||
"All five remain Needs Testing. Generated talent allocations need authored review.",
|
||||
"",
|
||||
"| Class | Specialization | Role | All class nodes | First-party nodes | Nodes with retained source operations | Global source-gap nodes |",
|
||||
"| --- | --- | --- | ---: | ---: | ---: | ---: |",
|
||||
...classes.map((entry) => `| ${entry.classId} | ${entry.specialization} | ${entry.role} | ${entry.counts.classNodes} | ${entry.counts.firstPartyNodes} | ${entry.counts.nodesWithRetainedSourceOperations} | ${entry.counts.sourceGapNodes} |`),
|
||||
"",
|
||||
"## Repeatable diagnostics",
|
||||
"",
|
||||
`Bosses: ${selectedBosses.map((boss) => `${boss.name} (${boss.key})`).join(", ")}.`,
|
||||
"Five seat configurations keep the same classes and build IDs while moving the active player.",
|
||||
"The full node checklist, spell IDs, descriptions, and source-operation review flags are in audit.json.",
|
||||
"No encounter simulation runs during preparation.",
|
||||
"",
|
||||
`Source catalog SHA-256: ${sourceSha256}.`,
|
||||
].join("\n");
|
||||
await writeFile(resolve(output, "audit.md"), `${markdown}\n`);
|
||||
console.log(JSON.stringify({ output, classes: classes.map(({ classId, specialization, counts }) => ({ classId, specialization, counts })), configurations: 5, simulatedEncounters: 0 }, null, 2));
|
||||
}
|
||||
@@ -1005,6 +1005,8 @@ function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()
|
||||
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),
|
||||
durationMs: durations.get(unsigned(40)) ?? 0,
|
||||
effects: Array.from({ length: 3 }, (_, effectIndex) => ({
|
||||
effectType: unsigned(71 + effectIndex),
|
||||
@@ -1246,6 +1248,12 @@ function spellAttack(spell, hash, boss) {
|
||||
delivery: target === "self" ? "area" : area ? "area" : melee ? "melee" : "projectile",
|
||||
target,
|
||||
school: spell.school,
|
||||
schoolMask: spell.schoolMask,
|
||||
damageOrigin: "wow335",
|
||||
damageClass: spell.damageClass,
|
||||
...(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,
|
||||
cooldownMs: Math.max(2_500, Math.min(12_000, spell.recoveryTimeMs || 4_000 + hash % 3_500)),
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const campaignFile = path.join(projectRoot, "src/game/generated/dungeonCampaignCatalog.json");
|
||||
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline/boss-mechanics.json");
|
||||
const runewakerSpellFile = path.join(
|
||||
projectRoot,
|
||||
"scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
);
|
||||
const outputFile = path.join(projectRoot, "src/game/generated/enemyDamageTypeSources.generated.json");
|
||||
const defaultWowDbc = path.resolve(
|
||||
projectRoot,
|
||||
"../wow-pvt-server-backups-2/wow335a/Data/patch-A.MPQ/DBFilesClient/Spell.dbc",
|
||||
);
|
||||
|
||||
const DAMAGE_EFFECTS = new Set([1, 2, 7, 9, 17, 31, 58, 62, 121]);
|
||||
const WOW_SCHOOL_BITS = [
|
||||
[1, "physical"],
|
||||
[2, "holy"],
|
||||
[4, "fire"],
|
||||
[8, "nature"],
|
||||
[16, "frost"],
|
||||
[32, "shadow"],
|
||||
[64, "arcane"],
|
||||
];
|
||||
const ROM_CHANNELS = ["earth", "water", "fire", "wind", "light", "darkness"];
|
||||
const ROM_RUNTIME_SCHOOLS = ["nature", "frost", "fire", "nature", "holy", "shadow"];
|
||||
const ROM_PHYSICAL_RUNTIME_SCHOOLS = { physical: "physical" };
|
||||
const ROM_PHYSICAL_CALCULATION_TYPES = new Set([0, 2, 3, 9]);
|
||||
const ROM_MAGICAL_CALCULATION_TYPES = new Set([1, 10]);
|
||||
|
||||
function sha256(buffer) {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function readWord(index, fieldCount, localizedShift) {
|
||||
const sourceField = index >= 77 ? index + localizedShift : index;
|
||||
assert(sourceField < fieldCount, `Spell.dbc field ${sourceField} is outside the record.`);
|
||||
return sourceField;
|
||||
}
|
||||
|
||||
function parseWowDamageRecords(buffer, wantedIds) {
|
||||
assert(buffer.length >= 20 && buffer.toString("ascii", 0, 4) === "WDBC", "Spell.dbc is not WDBC data.");
|
||||
const recordCount = buffer.readUInt32LE(4);
|
||||
const fieldCount = buffer.readUInt32LE(8);
|
||||
const recordSize = buffer.readUInt32LE(12);
|
||||
const stringSize = buffer.readUInt32LE(16);
|
||||
assert(fieldCount >= 226 && recordSize >= fieldCount * 4, "Spell.dbc lacks required 3.3.5 fields.");
|
||||
assert(20 + recordCount * recordSize + stringSize <= buffer.length, "Spell.dbc has invalid record/string bounds.");
|
||||
const localizedShift = fieldCount >= 239 ? 6 : 0;
|
||||
const stringsOffset = 20 + recordCount * recordSize;
|
||||
const results = new Map();
|
||||
const readName = (base) => {
|
||||
for (let locale = 0; locale < 16; locale += 1) {
|
||||
const field = readWord(136 + locale, fieldCount, localizedShift);
|
||||
const offset = buffer.readUInt32LE(base + field * 4);
|
||||
if (!offset || offset >= stringSize) continue;
|
||||
const start = stringsOffset + offset;
|
||||
const end = buffer.indexOf(0, start);
|
||||
const value = buffer.toString("utf8", start, end < 0 ? buffer.length : end).replace(/\s+/g, " ").trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
for (let row = 0; row < recordCount; row += 1) {
|
||||
const base = 20 + row * recordSize;
|
||||
const spellId = buffer.readUInt32LE(base);
|
||||
if (!wantedIds.has(spellId)) continue;
|
||||
const unsigned = (field) => buffer.readUInt32LE(base + readWord(field, fieldCount, localizedShift) * 4);
|
||||
const effectIds = Array.from({ length: 3 }, (_, index) => unsigned(71 + index));
|
||||
const damageEffects = effectIds.filter((effectId) => DAMAGE_EFFECTS.has(effectId));
|
||||
// Utility/script spells such as Call Flames are not source damage merely
|
||||
// because a DBC record has a school. Keep their authored runtime school.
|
||||
if (!damageEffects.length) continue;
|
||||
const schoolMask = unsigned(225);
|
||||
const schools = WOW_SCHOOL_BITS.filter(([bit]) => (schoolMask & bit) !== 0).map(([, school]) => school);
|
||||
const damageClass = unsigned(213);
|
||||
results.set(spellId, {
|
||||
spellId,
|
||||
name: readName(base),
|
||||
damageClass,
|
||||
schoolMask,
|
||||
schools,
|
||||
...(schools.length === 1 ? { school: schools[0] } : {}),
|
||||
...(damageClass === 1 ? { attackKind: "spell" }
|
||||
: damageClass === 2 ? { attackKind: "melee" }
|
||||
: damageClass === 3 ? { attackKind: "ranged" } : {}),
|
||||
damageEffects: [...new Set(damageEffects)].sort((left, right) => left - right),
|
||||
source: "WoW 3.3.5a Spell.dbc; AzerothCore DBCStructure.h fields 213/225",
|
||||
});
|
||||
}
|
||||
return { records: results, recordCount, fieldCount, recordSize };
|
||||
}
|
||||
|
||||
function spellIdsFromCampaign(campaign) {
|
||||
const ids = new Set();
|
||||
let inspectedAttackCount = 0;
|
||||
for (const definition of campaign.definitions ?? []) {
|
||||
for (const entity of Object.values(definition.entities ?? {})) {
|
||||
for (const attack of entity.combat?.attacks ?? []) {
|
||||
const spellId = Number(attack.spellId);
|
||||
if (Number.isSafeInteger(spellId) && spellId > 0) {
|
||||
ids.add(spellId);
|
||||
inspectedAttackCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ids, inspectedAttackCount };
|
||||
}
|
||||
|
||||
function spellIdsFromBossMechanics(catalog) {
|
||||
const ids = new Set();
|
||||
for (const boss of Object.values(catalog.bosses ?? {})) {
|
||||
// This catalog also contains Runewaker boss entries. Their numeric IDs
|
||||
// belong to a different spell namespace and must never be matched in DBC.
|
||||
if (boss.source?.kind === "runewaker") continue;
|
||||
for (const mechanic of boss.mechanics ?? []) {
|
||||
const spellId = Number(mechanic.spellId);
|
||||
if (Number.isSafeInteger(spellId) && spellId > 0) ids.add(spellId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function runewakerSourceDamageClass(calculationType) {
|
||||
if (ROM_PHYSICAL_CALCULATION_TYPES.has(Number(calculationType))) return "physical";
|
||||
if (ROM_MAGICAL_CALCULATION_TYPES.has(Number(calculationType))) return "magical";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function runewakerDirectFormula(calculationType) {
|
||||
switch (Number(calculationType)) {
|
||||
case 2: return "throw";
|
||||
case 4: return "fixed";
|
||||
case 8: return "target-hp-percent";
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function runewakerFormulaMitigation(calculationType, effectType, ignoreMagicAbsorb, isFixDotPower) {
|
||||
if (ignoreMagicAbsorb || (Number(calculationType) === 8 && isFixDotPower)) return "bypassed";
|
||||
switch (Number(calculationType)) {
|
||||
case 2: return "weapon-absorb";
|
||||
case 4: return "magic-absorb";
|
||||
case 8: return Number(effectType) === 1 ? "weapon-absorb" : "magic-absorb";
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function runewakerAttackKind(calculationType) {
|
||||
const value = Number(calculationType);
|
||||
if (value === 0 || value === 9) return "melee";
|
||||
if (value === 2) return "ranged";
|
||||
if (value === 3) return "ranged";
|
||||
if (value === 1 || value === 10) return "spell";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function runewakerSourceChannel(magicType) {
|
||||
const value = Number(magicType);
|
||||
if (value === -1) return "physical";
|
||||
return ROM_CHANNELS[value];
|
||||
}
|
||||
|
||||
function runewakerRuntimeSchool(sourceChannel) {
|
||||
if (sourceChannel === "physical") return ROM_PHYSICAL_RUNTIME_SCHOOLS.physical;
|
||||
const index = ROM_CHANNELS.indexOf(sourceChannel);
|
||||
return index >= 0 ? ROM_RUNTIME_SCHOOLS[index] : undefined;
|
||||
}
|
||||
|
||||
function romRecord(spell) {
|
||||
const components = (spell.components ?? [])
|
||||
.filter((component) => component.directHpDamage)
|
||||
.map((component) => {
|
||||
const rawChannel = Number(component.magicType);
|
||||
const sourceDamageClass = runewakerSourceDamageClass(component.calculationType);
|
||||
const sourceChannel = runewakerSourceChannel(rawChannel);
|
||||
const school = runewakerRuntimeSchool(sourceChannel);
|
||||
return {
|
||||
componentId: Number(component.id),
|
||||
calculationType: Number(component.calculationType),
|
||||
effectType: Number(component.effectType),
|
||||
magicType: rawChannel,
|
||||
...(runewakerDirectFormula(component.calculationType)
|
||||
? { directFormula: runewakerDirectFormula(component.calculationType) } : {}),
|
||||
...(runewakerFormulaMitigation(component.calculationType, component.effectType, component.ignoreMagicAbsorb, component.isFixDotPower)
|
||||
? { formulaMitigation: runewakerFormulaMitigation(component.calculationType, component.effectType, component.ignoreMagicAbsorb, component.isFixDotPower) } : {}),
|
||||
...(sourceDamageClass ? { sourceDamageClass } : {}),
|
||||
...(runewakerAttackKind(component.calculationType)
|
||||
? { attackKind: runewakerAttackKind(component.calculationType) } : {}),
|
||||
...(sourceChannel ? { sourceChannel } : {}),
|
||||
...(school ? { school } : {}),
|
||||
};
|
||||
});
|
||||
if (!components.length) return null;
|
||||
return {
|
||||
spellId: Number(spell.spellId),
|
||||
name: spell.name,
|
||||
components,
|
||||
source: "RuneWaker v0.20 MagicObjectDB; MagicResistENUM in ObjectStruct.h",
|
||||
};
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
let wowDbc = defaultWowDbc;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === "--wow-dbc") {
|
||||
wowDbc = path.resolve(argv[index + 1] ?? "");
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return { wowDbc };
|
||||
}
|
||||
|
||||
const { wowDbc } = parseArguments(process.argv.slice(2));
|
||||
const [campaignBytes, bossMechanicsBytes, runewakerSpellBytes, wowDbcBytes] = await Promise.all([
|
||||
readFile(campaignFile),
|
||||
readFile(bossMechanicsFile),
|
||||
readFile(runewakerSpellFile),
|
||||
readFile(wowDbc),
|
||||
]);
|
||||
const campaign = JSON.parse(campaignBytes.toString("utf8"));
|
||||
const bossMechanics = JSON.parse(bossMechanicsBytes.toString("utf8"));
|
||||
const runewakerCatalog = JSON.parse(runewakerSpellBytes.toString("utf8"));
|
||||
const campaignIds = spellIdsFromCampaign(campaign);
|
||||
const wowSpellIds = new Set([...campaignIds.ids, ...spellIdsFromBossMechanics(bossMechanics)]);
|
||||
const wow = parseWowDamageRecords(wowDbcBytes, wowSpellIds);
|
||||
const runewaker = Object.fromEntries((runewakerCatalog.spells ?? [])
|
||||
.map((spell) => [String(spell.spellId), romRecord(spell)])
|
||||
.filter(([, record]) => record !== null));
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
wowSpellDbc: path.relative(projectRoot, wowDbc).replaceAll(path.sep, "/"),
|
||||
wowSpellDbcSha256: sha256(wowDbcBytes),
|
||||
runewakerNpcSpellCatalog: path.relative(projectRoot, runewakerSpellFile).replaceAll(path.sep, "/"),
|
||||
runewakerNpcSpellCatalogSha256: sha256(runewakerSpellBytes),
|
||||
wowRecordLayout: `${wow.fieldCount} fields; ${wow.recordSize} byte rows`,
|
||||
wowSpellDamageFields: { damageClass: 213, schoolMask: 225, effects: [71, 72, 73] },
|
||||
runewakerCalculationTypePolicy: {
|
||||
physical: [0, 3, 9],
|
||||
magical: [1, 10],
|
||||
directFormulaTypes: { "2": "throw", "4": "fixed", "8": "target-hp-percent" },
|
||||
formulaMitigationIsIndependentOfMagicTypeAndCollectEffectType: true,
|
||||
attackKind: { "0": "melee", "1": "spell", "3": "ranged", "9": "melee", "10": "spell" },
|
||||
unsupportedTypesRemainUnclassified: true,
|
||||
},
|
||||
runewakerChannelEnum: "MagicType -1 is physical; values 0..5 are Earth, Water, Fire, Wind, Light, Darkness",
|
||||
},
|
||||
policy: {
|
||||
wowLookupScope: "spell ids used by the current compiled campaign enemy attacks or boss mechanics",
|
||||
wowDamageEffectsOnly: [...DAMAGE_EFFECTS].sort((left, right) => left - right),
|
||||
wowCompositeMaskPolicy: "preserve recognized schools; export `school` only for a single-bit mask",
|
||||
runewakerLookupScope: "NPC spell catalog entries with positively classified direct HP damage components",
|
||||
runewakerCompatibilitySchoolPolicy: "source packet class comes from MagicObject.Attack.CalType; exact MagicType channel is preserved independently and mapped to a best-fit runtime school",
|
||||
sourceIdentityKey: "origin plus spellId; RuneWaker numeric ids are never matched against WoW entries",
|
||||
},
|
||||
counts: {
|
||||
wowCandidateSpellIds: wowSpellIds.size,
|
||||
wowExportedDamageSpells: wow.records.size,
|
||||
wowDirectDamageEffectSpells: [...wow.records.values()].filter((entry) => entry.damageEffects.includes(2)).length,
|
||||
wowCompositeSchoolMasks: [...wow.records.values()].filter((entry) => entry.schools.length > 1).length,
|
||||
runewakerCatalogSpells: (runewakerCatalog.spells ?? []).length,
|
||||
runewakerExportedDamageSpells: Object.keys(runewaker).length,
|
||||
runewakerDamageComponents: Object.values(runewaker).reduce((sum, entry) => sum + entry.components.length, 0),
|
||||
campaignEnemyAttackReferences: campaignIds.inspectedAttackCount,
|
||||
},
|
||||
wow335: Object.fromEntries([...wow.records.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([spellId, record]) => [String(spellId), record])),
|
||||
runewaker: Object.fromEntries(Object.entries(runewaker).sort(([left], [right]) => Number(left) - Number(right))),
|
||||
};
|
||||
await writeFile(outputFile, JSON.stringify(output, null, 2) + "\n", "utf8");
|
||||
console.log(`[enemy-damage-types] exported ${output.counts.wowExportedDamageSpells} WoW and ${output.counts.runewakerExportedDamageSpells} RuneWaker source spells.`);
|
||||
console.log(`[enemy-damage-types] ${path.relative(projectRoot, outputFile)}`);
|
||||
@@ -41,6 +41,20 @@ const epochDungeonSourceFile = path.join(
|
||||
*/
|
||||
export const DEFAULT_AZEROTHCORE_COMMIT = "4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3";
|
||||
|
||||
export const PINNED_CREATURE_IMMUNITIES_SOURCE = Object.freeze({
|
||||
repository: "https://github.com/azerothcore/azerothcore-wotlk",
|
||||
commit: DEFAULT_AZEROTHCORE_COMMIT,
|
||||
path: "data/sql/base/db_world/creature_immunities.sql",
|
||||
url: `https://raw.githubusercontent.com/azerothcore/azerothcore-wotlk/${DEFAULT_AZEROTHCORE_COMMIT}/data/sql/base/db_world/creature_immunities.sql`,
|
||||
sha256: "aee81a1d8b99986f8498a2b4c51b64eaf4b9095a0d1a12bbf3705d9588a90a13",
|
||||
fixtureRoot: path.join(
|
||||
projectRoot,
|
||||
"dungeon-pipeline",
|
||||
"source-fixtures",
|
||||
`azerothcore-${DEFAULT_AZEROTHCORE_COMMIT}`,
|
||||
),
|
||||
});
|
||||
|
||||
const SOURCE_TABLES = Object.freeze([
|
||||
"creature",
|
||||
"creature_template",
|
||||
@@ -303,16 +317,37 @@ function bitMaskNames(maskValue, names, bitOffset = 0) {
|
||||
));
|
||||
}
|
||||
|
||||
function creatureTemplateReference(row) {
|
||||
export function creatureTemplateReference(row, creatureImmunitiesById = new Map()) {
|
||||
const entry = numeric(firstValue(row, ["entry", "CreatureID", "creatureId"]));
|
||||
const mechanicImmuneMask = numeric(firstValue(
|
||||
row,
|
||||
["mechanic_immune_mask", "mechanicImmuneMask", "CreatureImmunitiesId"],
|
||||
));
|
||||
const spellSchoolImmuneMask = numeric(firstValue(
|
||||
const legacyMechanicImmuneMask = firstValue(row, ["mechanic_immune_mask", "mechanicImmuneMask"]);
|
||||
const creatureImmunitiesId = firstValue(row, ["CreatureImmunitiesId", "creatureImmunitiesId"]);
|
||||
let mechanicImmuneMask = numeric(legacyMechanicImmuneMask);
|
||||
let spellSchoolImmuneMask = numeric(firstValue(
|
||||
row,
|
||||
["spell_school_immune_mask", "spellSchoolImmuneMask"],
|
||||
));
|
||||
if (legacyMechanicImmuneMask === undefined && creatureImmunitiesId !== undefined) {
|
||||
const immunityId = numeric(creatureImmunitiesId);
|
||||
if (immunityId !== 0) {
|
||||
const immunity = creatureImmunitiesById.get(immunityId);
|
||||
if (!immunity) {
|
||||
throw new Error(
|
||||
`Creature ${entry} references missing creature_immunities.ID ${immunityId}.`,
|
||||
);
|
||||
}
|
||||
const sourceMechanicsMask = numeric(firstValue(immunity, ["MechanicsMask", "mechanicsMask"]));
|
||||
if (!Number.isSafeInteger(sourceMechanicsMask) || sourceMechanicsMask < 0) {
|
||||
throw new Error(
|
||||
`creature_immunities.ID ${immunityId} has an unsupported MechanicsMask ${sourceMechanicsMask}.`,
|
||||
);
|
||||
}
|
||||
// CreatureImmunities.Mechanic stores bit N at the mechanic enum value N.
|
||||
// The legacy creature_template mask stores bit N-1. Normalize back to
|
||||
// that established runtime representation before decoding names below.
|
||||
mechanicImmuneMask = Math.floor(sourceMechanicsMask / 2);
|
||||
spellSchoolImmuneMask = numeric(firstValue(immunity, ["SchoolMask", "schoolMask"]));
|
||||
}
|
||||
}
|
||||
return {
|
||||
entry,
|
||||
name: String(firstValue(row, ["name", "Name"]) ?? `Creature ${entry}`),
|
||||
@@ -589,7 +624,13 @@ function parseColumns(sql, tableName) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
async function parseTable(sourceRoot, tableName, visitor) {
|
||||
export function creatureTemplateUsesImmunitiesReference(sql) {
|
||||
return parseColumns(sql, "creature_template").some((column) => (
|
||||
column === "CreatureImmunitiesId" || column === "creatureImmunitiesId"
|
||||
));
|
||||
}
|
||||
|
||||
export async function parseTable(sourceRoot, tableName, visitor) {
|
||||
const file = path.join(sourceRoot, `${tableName}.sql`);
|
||||
const buffer = await readFile(file);
|
||||
const sql = buffer.toString("utf8");
|
||||
@@ -627,6 +668,27 @@ async function fetchSources(sourceRoot, commit) {
|
||||
sha256: sha256(buffer),
|
||||
});
|
||||
}
|
||||
const creatureTemplateSql = (await readFile(path.join(sourceRoot, "creature_template.sql"))).toString("utf8");
|
||||
if (creatureTemplateUsesImmunitiesReference(creatureTemplateSql)) {
|
||||
const tableName = "creature_immunities";
|
||||
const url = `${base}/${tableName}.sql`;
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": "healer-man-dungeon-pipeline" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${tableName}: ${response.status} ${response.statusText} while fetching ${url}`);
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const file = path.join(sourceRoot, `${tableName}.sql`);
|
||||
await writeFile(file, buffer);
|
||||
reports.push({
|
||||
table: tableName,
|
||||
url,
|
||||
file: path.relative(projectRoot, file).replace(/\\/g, "/"),
|
||||
byteLength: buffer.length,
|
||||
sha256: sha256(buffer),
|
||||
});
|
||||
}
|
||||
await writeJson(path.join(sourceRoot, "source.lock.json"), {
|
||||
schemaVersion: 1,
|
||||
repository: "https://github.com/azerothcore/azerothcore-wotlk",
|
||||
@@ -857,6 +919,42 @@ export async function importAzerothCoreWorld({
|
||||
}
|
||||
}));
|
||||
|
||||
const creatureImmunitiesById = new Map();
|
||||
const creatureTemplateSql = (await readFile(
|
||||
path.join(resolvedSourceRoot, "creature_template.sql"),
|
||||
)).toString("utf8");
|
||||
if (creatureTemplateUsesImmunitiesReference(creatureTemplateSql)) {
|
||||
let creatureImmunitiesSourceRoot = resolvedSourceRoot;
|
||||
try {
|
||||
await readFile(path.join(creatureImmunitiesSourceRoot, "creature_immunities.sql"));
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT" || commit !== PINNED_CREATURE_IMMUNITIES_SOURCE.commit) throw error;
|
||||
creatureImmunitiesSourceRoot = PINNED_CREATURE_IMMUNITIES_SOURCE.fixtureRoot;
|
||||
}
|
||||
const creatureImmunitiesReport = await parseTable(
|
||||
creatureImmunitiesSourceRoot,
|
||||
"creature_immunities",
|
||||
(row) => {
|
||||
const id = numeric(firstValue(row, ["ID", "id"]));
|
||||
if (creatureImmunitiesById.has(id)) {
|
||||
throw new Error(`creature_immunities contains duplicate ID ${id}.`);
|
||||
}
|
||||
creatureImmunitiesById.set(id, row);
|
||||
},
|
||||
);
|
||||
if (commit === PINNED_CREATURE_IMMUNITIES_SOURCE.commit
|
||||
&& creatureImmunitiesReport.sha256 !== PINNED_CREATURE_IMMUNITIES_SOURCE.sha256) {
|
||||
throw new Error(
|
||||
`creature_immunities.sql does not match pinned ${commit} SHA-256 `
|
||||
+ `${PINNED_CREATURE_IMMUNITIES_SOURCE.sha256}.`,
|
||||
);
|
||||
}
|
||||
tableReports.push({
|
||||
...creatureImmunitiesReport,
|
||||
url: `https://raw.githubusercontent.com/azerothcore/azerothcore-wotlk/${commit}/data/sql/base/db_world/creature_immunities.sql`,
|
||||
});
|
||||
}
|
||||
|
||||
const templateByEntry = new Map();
|
||||
tableReports.push(await parseTable(resolvedSourceRoot, "creature_template", (row) => {
|
||||
const entry = numeric(firstValue(row, ["entry", "CreatureID", "creatureId"]));
|
||||
@@ -1137,7 +1235,7 @@ export async function importAzerothCoreWorld({
|
||||
const normalizedTemplates = Object.fromEntries(
|
||||
[...templateByEntry.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([entry, row]) => [entry, creatureTemplateReference(row)]),
|
||||
.map(([entry, row]) => [entry, creatureTemplateReference(row, creatureImmunitiesById)]),
|
||||
);
|
||||
const normalizedCreatureClassStats = Object.fromEntries(
|
||||
creatureClassLevelStats
|
||||
@@ -1170,9 +1268,10 @@ export async function importAzerothCoreWorld({
|
||||
}
|
||||
}
|
||||
|
||||
const sourceFiles = tableReports.map(({ file, columns, rows, byteLength, sha256: hash }) => ({
|
||||
const sourceFiles = tableReports.map(({ file, columns, rows, byteLength, sha256: hash, url }) => ({
|
||||
table: path.basename(file, ".sql"),
|
||||
file: path.basename(file),
|
||||
...(url ? { url } : {}),
|
||||
columns,
|
||||
rows,
|
||||
byteLength,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { before, describe, it } from "node:test";
|
||||
import {
|
||||
creatureTemplateReference,
|
||||
creatureTemplateUsesImmunitiesReference,
|
||||
parseTable,
|
||||
PINNED_CREATURE_IMMUNITIES_SOURCE,
|
||||
} from "./import-azerothcore-world.mjs";
|
||||
|
||||
describe("AzerothCore creature immunity import", () => {
|
||||
let creatureImmunitiesById;
|
||||
let tableReport;
|
||||
|
||||
before(async () => {
|
||||
creatureImmunitiesById = new Map();
|
||||
tableReport = await parseTable(
|
||||
PINNED_CREATURE_IMMUNITIES_SOURCE.fixtureRoot,
|
||||
"creature_immunities",
|
||||
(row) => creatureImmunitiesById.set(Number(row.ID), row),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the pinned schema, source commit, and exact table hash", () => {
|
||||
assert.equal(PINNED_CREATURE_IMMUNITIES_SOURCE.commit, "4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3");
|
||||
assert.equal(PINNED_CREATURE_IMMUNITIES_SOURCE.path, "data/sql/base/db_world/creature_immunities.sql");
|
||||
assert.equal(PINNED_CREATURE_IMMUNITIES_SOURCE.sha256,
|
||||
"aee81a1d8b99986f8498a2b4c51b64eaf4b9095a0d1a12bbf3705d9588a90a13");
|
||||
assert.ok(tableReport.rows > 0);
|
||||
assert.equal(tableReport.sha256, PINNED_CREATURE_IMMUNITIES_SOURCE.sha256);
|
||||
assert.ok([
|
||||
"ID",
|
||||
"SchoolMask",
|
||||
"MechanicsMask",
|
||||
].every((column) => tableReport.columns.includes(column)));
|
||||
assert.deepEqual({
|
||||
ID: creatureImmunitiesById.get(-93).ID,
|
||||
SchoolMask: creatureImmunitiesById.get(-93).SchoolMask,
|
||||
MechanicsMask: creatureImmunitiesById.get(-93).MechanicsMask,
|
||||
}, {
|
||||
ID: -93,
|
||||
SchoolMask: 0,
|
||||
MechanicsMask: 16_777_248,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves Lady Anacondra's immunity FK to Fear and Horror only", () => {
|
||||
const lady = creatureTemplateReference({
|
||||
entry: 3671,
|
||||
name: "Lady Anacondra",
|
||||
CreatureImmunitiesId: -93,
|
||||
}, creatureImmunitiesById);
|
||||
|
||||
assert.equal(lady.entry, 3671);
|
||||
assert.equal(lady.mechanicImmuneMask, 0x0080_0010);
|
||||
assert.deepEqual(lady.mechanicImmunities, ["fear", "horror"]);
|
||||
assert.equal(lady.spellSchoolImmuneMask, 0);
|
||||
assert.deepEqual(lady.schoolImmunities, []);
|
||||
assert.ok(!lady.mechanicImmunities.includes("silence"));
|
||||
assert.ok(!lady.mechanicImmunities.includes("interrupt"));
|
||||
});
|
||||
|
||||
it("retains source-backed Silence and Interrupt immunity on rows that declare them", () => {
|
||||
const immune = creatureTemplateReference({
|
||||
entry: 99_377,
|
||||
name: "Source immunity control",
|
||||
CreatureImmunitiesId: -377,
|
||||
}, creatureImmunitiesById);
|
||||
|
||||
assert.ok(immune.mechanicImmunities.includes("silence"));
|
||||
assert.ok(immune.mechanicImmunities.includes("interrupt"));
|
||||
});
|
||||
|
||||
it("preserves legacy literal masks and fails closed on a missing FK", () => {
|
||||
const legacy = creatureTemplateReference({
|
||||
entry: 1,
|
||||
mechanic_immune_mask: 0x0080_0010,
|
||||
spell_school_immune_mask: 0x04,
|
||||
});
|
||||
assert.equal(legacy.mechanicImmuneMask, 0x0080_0010);
|
||||
assert.deepEqual(legacy.mechanicImmunities, ["fear", "horror"]);
|
||||
assert.equal(legacy.spellSchoolImmuneMask, 0x04);
|
||||
assert.deepEqual(legacy.schoolImmunities, ["fire"]);
|
||||
assert.throws(() => creatureTemplateReference({
|
||||
entry: 3671,
|
||||
CreatureImmunitiesId: -999_999,
|
||||
}, creatureImmunitiesById), /references missing creature_immunities\.ID -999999/);
|
||||
});
|
||||
|
||||
it("loads the FK table only for schemas that declare CreatureImmunitiesId", () => {
|
||||
const legacySchema = "CREATE TABLE `creature_template` (\n `mechanic_immune_mask` int NOT NULL DEFAULT '0'\n) ENGINE=InnoDB;";
|
||||
const fkSchema = "CREATE TABLE `creature_template` (\n `CreatureImmunitiesId` int NOT NULL DEFAULT '0'\n) ENGINE=InnoDB;";
|
||||
|
||||
assert.equal(creatureTemplateUsesImmunitiesReference(legacySchema), false);
|
||||
assert.equal(creatureTemplateUsesImmunitiesReference(fkSchema), true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,893 @@
|
||||
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');
|
||||
|
||||
// UI-driven campaign route QA. Only the isolated profile's level and RoM
|
||||
// secondary class are prepared directly; dungeon entry, movement, targeting,
|
||||
// combat actions, and ally selection all use the ordinary UI and keyboard.
|
||||
const artifactRoot = path.resolve('playtest-artifacts/native-routes');
|
||||
const stamp = new Date().toISOString().replace(/[-:.]/g, '').replace('T', '-').replace('Z', '');
|
||||
const baseline = process.argv.includes('--baseline');
|
||||
const outputDirectory = path.join(artifactRoot, `${baseline ? 'baseline' : 'final'}-${stamp}`);
|
||||
const durationOption = process.argv.find((value) => value.startsWith('--route-seconds='));
|
||||
const routeDurationMs = Math.max(15, Number(durationOption?.split('=')[1] ?? (baseline ? 25 : 90))) * 1_000;
|
||||
const routeSelection = process.argv.find((value) => value.startsWith('--route='))?.split('=')[1] ?? null;
|
||||
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-native-routes-'));
|
||||
const moduleFiles = fs.readdirSync('dist/assets')
|
||||
.filter((file) => /^(?:index|GameRuntime|combatStore|partyStore|PartyFrames)-.*\.js$/.test(file));
|
||||
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' },
|
||||
];
|
||||
const routesToRun = routeSelection ? routes.filter((route) => route.id === routeSelection) : routes;
|
||||
const actionSlots = [
|
||||
['primary:face-bottom', '1'], ['primary:face-left', '2'], ['primary:face-top', '3'],
|
||||
['primary:face-right', '4'], ['primary:r1', '5'], ['primary:r2', '6'],
|
||||
['primary:dpad-left', '7'], ['primary:dpad-right', '8'],
|
||||
];
|
||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
|
||||
function distance3(left, right) {
|
||||
if (!left || !right) return Number.POSITIVE_INFINITY;
|
||||
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
|
||||
}
|
||||
|
||||
function distancePlanar(left, right) {
|
||||
if (!left || !right) return Number.POSITIVE_INFINITY;
|
||||
return Math.hypot(left[0] - right[0], left[2] - right[2]);
|
||||
}
|
||||
|
||||
function hasFinitePosition(position) {
|
||||
return Array.isArray(position) && position.length >= 3 && position.every(Number.isFinite);
|
||||
}
|
||||
|
||||
function escapeRegex(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function flatEffects(effects = []) {
|
||||
return effects.flatMap((effect) => effect.kind === 'composite' ? flatEffects(effect.effects) : [effect]);
|
||||
}
|
||||
|
||||
function actionPlanFromSnapshot(snapshot) {
|
||||
const abilities = new Map(snapshot.abilities.map((ability) => [ability.id, ability]));
|
||||
const bound = actionSlots.flatMap(([bindingId, key]) => {
|
||||
const id = snapshot.actionBindings[bindingId];
|
||||
const ability = id ? abilities.get(id) : null;
|
||||
return ability && !ability.passive && (ability.unlockLevel ?? 1) <= snapshot.player.level
|
||||
? [{ key, ability, effects: flatEffects(ability.effects) }]
|
||||
: [];
|
||||
});
|
||||
const damage = bound.filter(({ ability, effects }) => ability.target === 'hostile'
|
||||
&& effects.some((effect) => effect.kind === 'damage'))
|
||||
.sort((left, right) => Number(right.ability.range?.max ?? 0) - Number(left.ability.range?.max ?? 0))[0]
|
||||
?? bound.find(({ effects }) => effects.some((effect) => effect.kind === 'damage'));
|
||||
const healing = bound.find(({ ability, effects }) => ability.target === 'friendly'
|
||||
&& effects.some((effect) => effect.kind === 'heal'));
|
||||
return {
|
||||
damage: damage ? { key: damage.key, id: damage.ability.id, name: damage.ability.name,
|
||||
target: damage.ability.target, range: damage.ability.range ?? null,
|
||||
selectionReason: 'longest-range-bound-hostile-damage-ability' } : null,
|
||||
healing: healing ? { key: healing.key, id: healing.ability.id, name: healing.ability.name,
|
||||
target: healing.ability.target, range: healing.ability.range ?? null } : null,
|
||||
bound: bound.map(({ key, ability }) => ({ key, id: ability.id, name: ability.name,
|
||||
target: ability.target, range: ability.range ?? null })),
|
||||
};
|
||||
}
|
||||
|
||||
async function readLiveState(page, includeActionSetup = false) {
|
||||
return page.evaluate((includeSetup) => window.nativeRouteStores().then(({ game, combat, party,
|
||||
getMobPosition, mobPositionExports, getPartyPosition, partyPositionExports }) => {
|
||||
const gameState = game.getState();
|
||||
const combatState = combat.getState();
|
||||
return {
|
||||
now: Date.now(),
|
||||
game: {
|
||||
mobPositionExports,
|
||||
partyPositionExports,
|
||||
activeDungeonId: gameState.activeDungeonId,
|
||||
gameMode: gameState.gameMode,
|
||||
gmTestMode: gameState.gmTestMode,
|
||||
bossTest: gameState.bossTest,
|
||||
assetStatus: gameState.assetStatus,
|
||||
activeSpawn: gameState.activeSpawn,
|
||||
playerPosition: gameState.playerPosition,
|
||||
playerNavigationPosition: gameState.playerNavigationPosition,
|
||||
playerGrounded: gameState.playerGrounded,
|
||||
cameraYaw: gameState.cameraYaw,
|
||||
areaName: gameState.areaName,
|
||||
distanceTravelled: gameState.distanceTravelled,
|
||||
hasMoved: gameState.hasMoved,
|
||||
overlay: gameState.overlay,
|
||||
dungeonIntroActive: gameState.dungeonIntroActive,
|
||||
},
|
||||
player: {
|
||||
classId: combatState.classId,
|
||||
secondaryClassId: combatState.secondaryClassId,
|
||||
level: combatState.level,
|
||||
role: window.nativeRouteStoresRole ?? null,
|
||||
statRuleset: combatState.statRuleset,
|
||||
health: combatState.health,
|
||||
maxHealth: combatState.maxHealth,
|
||||
resource: combatState.resource,
|
||||
maxResource: combatState.maxResource,
|
||||
resourceType: combatState.resourceType,
|
||||
resourceName: combatState.resourceName,
|
||||
position: gameState.playerPosition,
|
||||
selectedTargetId: combatState.selectedTargetId,
|
||||
activeCast: combatState.activeCast?.abilityId ?? null,
|
||||
cooldowns: combatState.cooldowns,
|
||||
},
|
||||
party: party.getState().members.map((member) => ({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
classId: member.classId,
|
||||
secondaryClassId: member.secondaryClassId,
|
||||
role: member.role,
|
||||
statRuleset: member.statRuleset,
|
||||
health: member.health,
|
||||
maxHealth: member.maxHealth,
|
||||
resource: member.resource,
|
||||
maxResource: member.maxResource,
|
||||
resourceType: member.resourceType,
|
||||
status: member.status,
|
||||
activeCast: member.activeCast?.abilityId ?? null,
|
||||
position: getPartyPosition?.(member.id) ?? null,
|
||||
})),
|
||||
mobs: Object.values(combatState.mobs).filter((mob) => !mob.despawned).map((mob) => ({
|
||||
id: mob.id,
|
||||
name: mob.name,
|
||||
level: mob.level,
|
||||
health: mob.health,
|
||||
maxHealth: mob.maxHealth,
|
||||
dead: mob.dead,
|
||||
diedAt: mob.diedAt ?? null,
|
||||
engaged: mob.engaged,
|
||||
position: getMobPosition?.(mob.id) ?? mob.position ?? mob.homePosition,
|
||||
homePosition: mob.homePosition,
|
||||
attackRevision: mob.attackRevision,
|
||||
abilityCastCounts: mob.abilityCastCounts,
|
||||
activeCast: mob.activeCast ? { name: mob.activeCast.name, attackId: mob.activeCast.attackId } : null,
|
||||
boss: mob.boss,
|
||||
})),
|
||||
...(includeSetup ? { abilities: combatState.abilities, actionBindings: combatState.actionBindings } : {}),
|
||||
feedback: combatState.feedback ? { kind: combatState.feedback.kind,
|
||||
message: combatState.feedback.message, abilityId: combatState.feedback.abilityId ?? null,
|
||||
targetId: combatState.feedback.targetId ?? null } : null,
|
||||
events: combatState.combatEvents.slice(-200),
|
||||
};
|
||||
}), includeActionSetup);
|
||||
}
|
||||
|
||||
function liveMobs(snapshot) {
|
||||
return snapshot.mobs.filter((mob) => !mob.dead && !mob.despawned);
|
||||
}
|
||||
|
||||
function sourcePlayerEvent(event) {
|
||||
return event.sourceActorId === '__player__';
|
||||
}
|
||||
|
||||
async function selectHostileWithTab(page, routeResult, attempts = 10) {
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(80);
|
||||
const snapshot = await readLiveState(page);
|
||||
const target = snapshot.mobs.find((mob) => mob.id === snapshot.player.selectedTargetId
|
||||
&& !mob.dead && !mob.despawned && !mob.boss && hasFinitePosition(mob.position));
|
||||
routeResult.targeting.push({ at: snapshot.now, input: 'Tab', selectedTargetId: snapshot.player.selectedTargetId,
|
||||
targetName: target?.name ?? null, targetDistance: target ? distance3(snapshot.player.position, target.position) : null });
|
||||
if (target) return { snapshot, target };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function movementKeysTo(from, to, yaw) {
|
||||
const dx = to[0] - from[0];
|
||||
const dz = to[2] - from[2];
|
||||
const magnitude = Math.hypot(dx, dz) || 1;
|
||||
const worldX = dx / magnitude;
|
||||
const worldZ = dz / magnitude;
|
||||
const cosYaw = Math.cos(yaw);
|
||||
const sinYaw = Math.sin(yaw);
|
||||
const localX = -worldX * cosYaw + worldZ * sinYaw;
|
||||
const localForward = worldX * sinYaw + worldZ * cosYaw;
|
||||
const keys = [];
|
||||
if (localForward > 0.32) keys.push('w');
|
||||
else if (localForward < -0.32) keys.push('s');
|
||||
if (localX > 0.32) keys.push('d');
|
||||
else if (localX < -0.32) keys.push('a');
|
||||
return keys;
|
||||
}
|
||||
|
||||
async function walkToward(page, routeResult, destination, durationMs = 650) {
|
||||
const before = await readLiveState(page);
|
||||
const keys = movementKeysTo(before.player.position, destination, before.game.cameraYaw);
|
||||
if (!keys.length) return null;
|
||||
for (const key of keys) await page.keyboard.down(key);
|
||||
try {
|
||||
if (routeSelection && routesToRun.length !== 1) throw new Error(`Unknown native campaign route id: ${routeSelection}`);
|
||||
await page.waitForTimeout(durationMs);
|
||||
} finally {
|
||||
for (const key of keys) await page.keyboard.up(key);
|
||||
}
|
||||
await page.waitForTimeout(150);
|
||||
const after = await readLiveState(page);
|
||||
const step = {
|
||||
at: after.now,
|
||||
keys: keys.map((key) => key.toUpperCase()),
|
||||
durationMs,
|
||||
from: before.player.position,
|
||||
to: after.player.position,
|
||||
destination,
|
||||
planarDisplacement: distancePlanar(before.player.position, after.player.position),
|
||||
spatialDisplacement: distance3(before.player.position, after.player.position),
|
||||
gameDistanceTravelledDelta: after.game.distanceTravelled - before.game.distanceTravelled,
|
||||
targetDistanceBefore: distance3(before.player.position, destination),
|
||||
targetDistanceAfter: distance3(after.player.position, destination),
|
||||
blocked: distancePlanar(before.player.position, after.player.position) < 0.08,
|
||||
};
|
||||
routeResult.movement.push(step);
|
||||
return { snapshot: after, step };
|
||||
}
|
||||
|
||||
async function performCast(page, routeResult, action, kind, intendedTargetId, reason) {
|
||||
const before = await readLiveState(page);
|
||||
const since = before.now - 1;
|
||||
await page.keyboard.press(action.key);
|
||||
const castWaitStarted = Date.now();
|
||||
let after = await readLiveState(page);
|
||||
const relevantEventsFor = (state) => state.events.filter((event) => event.occurredAt >= since
|
||||
&& event.abilityId === action.id && sourcePlayerEvent(event)
|
||||
&& (kind === 'damage' ? event.kind === 'damage' : event.kind === 'healing'));
|
||||
while (Date.now() - castWaitStarted < 2_600) {
|
||||
const relevant = relevantEventsFor(after);
|
||||
if (relevant.some((event) => event.effectiveAmount > 0)
|
||||
|| (!after.player.activeCast && Date.now() - castWaitStarted >= 650)) break;
|
||||
await page.waitForTimeout(150);
|
||||
after = await readLiveState(page);
|
||||
}
|
||||
const relevantEvents = relevantEventsFor(after);
|
||||
const memberBefore = before.party.find((member) => member.id === intendedTargetId);
|
||||
const memberAfter = after.party.find((member) => member.id === intendedTargetId);
|
||||
const cast = {
|
||||
at: before.now,
|
||||
key: action.key,
|
||||
abilityId: action.id,
|
||||
abilityName: action.name,
|
||||
kind,
|
||||
reason,
|
||||
intendedTargetId,
|
||||
selectedTargetIdBefore: before.player.selectedTargetId,
|
||||
targetHealthBefore: kind === 'healing'
|
||||
? before.party.find((member) => member.id === intendedTargetId)?.health ?? null
|
||||
: before.mobs.find((mob) => mob.id === intendedTargetId)?.health ?? null,
|
||||
targetHealthAfter: kind === 'healing'
|
||||
? after.party.find((member) => member.id === intendedTargetId)?.health ?? null
|
||||
: after.mobs.find((mob) => mob.id === intendedTargetId)?.health ?? null,
|
||||
playerPositionAtInput: before.player.position,
|
||||
allyPositionAtInput: memberBefore?.position ?? null,
|
||||
allyDistanceAtInput: memberBefore?.position
|
||||
? distance3(before.player.position, memberBefore.position) : null,
|
||||
allyDistanceAfterCast: memberAfter?.position
|
||||
? distance3(after.player.position, memberAfter.position) : null,
|
||||
castWaitMs: Date.now() - castWaitStarted,
|
||||
resourceBefore: before.player.resource,
|
||||
resourceAfter: after.player.resource,
|
||||
resolvedEvents: relevantEvents.map((event) => ({ kind: event.kind, targetActorId: event.targetActorId,
|
||||
effectiveAmount: event.effectiveAmount, overheal: event.overheal, school: event.school ?? null,
|
||||
abilityId: event.abilityId, occurredAt: event.occurredAt })),
|
||||
actionResolved: relevantEvents.some((event) => event.effectiveAmount > 0),
|
||||
feedback: after.feedback,
|
||||
};
|
||||
routeResult.actions.push(cast);
|
||||
return { before, after, cast };
|
||||
}
|
||||
|
||||
async function selectPartyMember(page, member) {
|
||||
const label = new RegExp(`^Target ${escapeRegex(member.name)},`);
|
||||
// Accessible target labels are unique for native companions; getByRole keeps
|
||||
// the test on the same clickable party-frame that a player would use.
|
||||
await page.getByRole('button', { name: label }).click();
|
||||
}
|
||||
|
||||
async function rosterContinueHitTest(page, viewport) {
|
||||
return page.evaluate((viewportSize) => {
|
||||
const button = document.querySelector('.roster-enter');
|
||||
const toggle = document.querySelector('.single-context-toggle');
|
||||
if (!button) throw new Error('Roster Continue button is missing.');
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const toggleRect = toggle?.getBoundingClientRect() ?? null;
|
||||
const overlaps = Boolean(toggleRect
|
||||
&& buttonRect.left < toggleRect.right && buttonRect.right > toggleRect.left
|
||||
&& buttonRect.top < toggleRect.bottom && buttonRect.bottom > toggleRect.top);
|
||||
const center = { x: buttonRect.left + buttonRect.width / 2, y: buttonRect.top + buttonRect.height / 2 };
|
||||
const hit = document.elementFromPoint(center.x, center.y);
|
||||
return {
|
||||
viewport: viewportSize,
|
||||
buttonRect: { x: buttonRect.x, y: buttonRect.y, width: buttonRect.width, height: buttonRect.height },
|
||||
toggleRect: toggleRect ? { x: toggleRect.x, y: toggleRect.y,
|
||||
width: toggleRect.width, height: toggleRect.height } : null,
|
||||
overlaps,
|
||||
centerHit: hit?.className?.toString?.() ?? hit?.tagName ?? null,
|
||||
centerHitIsContinue: Boolean(hit && (hit === button || button.contains(hit))),
|
||||
};
|
||||
}, viewport);
|
||||
}
|
||||
|
||||
async function prepareCharacter(page) {
|
||||
await page.getByRole('button', { name: /Play offline/ }).click();
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
|
||||
await page.locator('.class-option').filter({ hasText: /^Priest/ }).click();
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill('RoutePriestQA');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.character-select-screen').waitFor();
|
||||
const profile = await page.evaluate(async () => {
|
||||
const { shell } = await window.nativeRouteStores();
|
||||
const state = shell.getState();
|
||||
const character = state.characters.find((entry) => entry.id === state.selectedCharacterId);
|
||||
if (!character || character.classId !== 'rom-priest') throw new Error('UI-created RoM Priest profile is missing.');
|
||||
shell.setState({ activeCharacter: character });
|
||||
shell.getState().saveActiveProgression(30, 0, character.talentRanks);
|
||||
shell.setState({ activeCharacter: null });
|
||||
if (!shell.getState().chooseRomSecondaryClass('rom-knight')) throw new Error('Priest/Knight pairing was not accepted.');
|
||||
const updated = shell.getState().characters.find((entry) => entry.id === character.id);
|
||||
return { id: updated.id, name: updated.name, classId: updated.classId,
|
||||
secondaryClassId: updated.secondaryClassId, level: updated.level, categoryId: updated.categoryId };
|
||||
});
|
||||
assert.equal(profile.classId, 'rom-priest');
|
||||
assert.equal(profile.secondaryClassId, 'rom-knight');
|
||||
assert.equal(profile.level, 30);
|
||||
await page.waitForTimeout(1_500);
|
||||
await page.screenshot({ path: path.join(outputDirectory, 'roster-priest-knight-level-30.png') });
|
||||
const desktopRosterHit = await rosterContinueHitTest(page, { width: 1440, height: 900 });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: path.join(outputDirectory, 'roster-compact-priest-knight-level-30.png') });
|
||||
const compactRosterHit = await rosterContinueHitTest(page, { width: 390, height: 844 });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.waitForTimeout(300);
|
||||
const rosterUi = { desktop: desktopRosterHit, compact: compactRosterHit,
|
||||
forceClickUsed: false };
|
||||
if (!baseline) {
|
||||
assert.equal(desktopRosterHit.overlaps, false, 'Desktop roster toggle does not overlap Continue.');
|
||||
assert.equal(desktopRosterHit.centerHitIsContinue, true, 'Desktop Continue button owns its center hit point.');
|
||||
assert.equal(compactRosterHit.overlaps, false, 'Compact roster toggle does not overlap Continue.');
|
||||
assert.equal(compactRosterHit.centerHitIsContinue, true, 'Compact Continue button owns its center hit point.');
|
||||
}
|
||||
if (desktopRosterHit.centerHitIsContinue) {
|
||||
await page.locator('.roster-enter').click();
|
||||
} else if (baseline) {
|
||||
// A baseline with the known floating-toggle obstruction may continue via
|
||||
// a forced pointer action for diagnostics, and records that exception.
|
||||
rosterUi.forceClickUsed = true;
|
||||
await page.locator('.roster-enter').click({ force: true });
|
||||
} else {
|
||||
throw new Error('Final-build roster Continue button is obscured at its center hit point.');
|
||||
}
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
return { profile, rosterUi };
|
||||
}
|
||||
|
||||
async function enterCampaignDungeon(page, route, isFirst, minimumLevel = 30) {
|
||||
if (!isFirst) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Main menu', exact: true }).waitFor({ state: 'visible' });
|
||||
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
}
|
||||
await page.getByRole('button', { name: /^Dungeons/ }).click();
|
||||
await page.locator('.dungeon-select-screen').waitFor();
|
||||
await page.locator('.dungeon-category-tab').filter({ hasText: route.category }).click();
|
||||
const exactTitle = new RegExp(`^${escapeRegex(route.title)}$`, 'i');
|
||||
const row = page.locator('.dungeon-select-row').filter({
|
||||
has: page.locator('strong').filter({ hasText: exactTitle }),
|
||||
});
|
||||
const exactRowCount = await row.count();
|
||||
assert.equal(exactRowCount, 1, `${route.title} has one exact source campaign row in ${route.category}`);
|
||||
await row.waitFor({ state: 'visible' });
|
||||
const selectedRowTitle = (await row.locator('strong').innerText()).trim();
|
||||
assert.equal(selectedRowTitle, route.title, `${route.title} campaign selection is exact`);
|
||||
await page.screenshot({ path: path.join(outputDirectory, `${route.id}-campaign-picker.png`) });
|
||||
await row.click();
|
||||
if (await page.getByRole('alertdialog').count()) {
|
||||
const confirmDownload = page.getByRole('button', { name: 'Download and continue', exact: true });
|
||||
if (await confirmDownload.count()) await confirmDownload.click();
|
||||
} else {
|
||||
await page.locator('.dungeon-select-enter').click();
|
||||
if (await page.getByRole('alertdialog').count()) {
|
||||
await page.getByRole('button', { name: 'Download and continue', exact: true }).click();
|
||||
}
|
||||
}
|
||||
await page.locator('.dungeon-role-option--healer').waitFor({ state: 'visible', timeout: 180_000 });
|
||||
await page.locator('.dungeon-role-option--healer').click();
|
||||
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
|
||||
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 180_000 }).catch(() => {});
|
||||
await page.waitForFunction(async (expectedId) => {
|
||||
const { game } = await window.nativeRouteStores();
|
||||
const state = game.getState();
|
||||
return state.activeDungeonId === expectedId && state.assetStatus === 'ready';
|
||||
}, route.id, { timeout: 240_000 });
|
||||
await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached', timeout: 15_000 }).catch(() => {});
|
||||
await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur());
|
||||
const state = await readLiveState(page);
|
||||
assert.equal(state.game.activeDungeonId, route.id, `${route.title} entered via the selected campaign route`);
|
||||
assert.equal(state.game.gameMode, 'dungeon');
|
||||
assert.equal(state.game.gmTestMode, false, 'campaign entry is not GM mode');
|
||||
assert.equal(state.game.bossTest, null, 'campaign entry is not Boss Testing');
|
||||
assert.equal(state.player.classId, 'rom-priest');
|
||||
assert.equal(state.player.secondaryClassId, 'rom-knight');
|
||||
assert(state.player.level >= minimumLevel,
|
||||
`${route.title} entry level remains at least the UI-created profile's level ${minimumLevel}`);
|
||||
assert.equal(state.party.length, 4, 'the UI-created Priest has four NPC companions');
|
||||
assert(state.party.every((member) => member.classId.startsWith('rom-') && member.statRuleset === 'rom'),
|
||||
'all four companions use native RoM identities and rules');
|
||||
await page.screenshot({ path: path.join(outputDirectory, `${route.id}-entrance.png`) });
|
||||
return state;
|
||||
}
|
||||
|
||||
async function runRouteSegment(page, route, entryState, profile) {
|
||||
const result = {
|
||||
dungeonId: route.id,
|
||||
title: route.title,
|
||||
coverage: 'campaign entrance through first live in-scene pack/encounter segment; no full route or boss-clear claim',
|
||||
enteredThrough: 'normal Dungeons picker and healer role selection',
|
||||
bossTesting: false,
|
||||
profile,
|
||||
playerAtEntry: entryState.player,
|
||||
partyAtEntry: entryState.party,
|
||||
dungeonEntry: entryState.game,
|
||||
startingPosition: entryState.player.position,
|
||||
actionPlan: actionPlanFromSnapshot(entryState),
|
||||
movement: [],
|
||||
healPursuits: [],
|
||||
targeting: [],
|
||||
actions: [],
|
||||
samples: [],
|
||||
observedCombatEvents: [],
|
||||
screenshots: {
|
||||
entrance: `${route.id}-entrance.png`,
|
||||
campaignPicker: `${route.id}-campaign-picker.png`,
|
||||
walk: `${route.id}-keyboard-traversal.png`,
|
||||
middle: `${route.id}-encounter-midpoint.png`,
|
||||
endpoint: `${route.id}-segment-end.png`,
|
||||
},
|
||||
segmentStartedAt: Date.now(),
|
||||
};
|
||||
const seenEventKeys = new Set(entryState.events.map((event) => [event.occurredAt, event.sourceActorId,
|
||||
event.targetActorId, event.abilityId, event.kind, event.effectiveAmount].join('|')));
|
||||
const observedDeadMobs = new Map();
|
||||
const collectCombatEvents = (snapshot) => {
|
||||
for (const mob of snapshot.mobs.filter((entry) => entry.dead)) observedDeadMobs.set(mob.id, mob.name);
|
||||
for (const event of snapshot.events) {
|
||||
const key = [event.occurredAt, event.sourceActorId, event.targetActorId,
|
||||
event.abilityId, event.kind, event.effectiveAmount].join('|');
|
||||
if (seenEventKeys.has(key)) continue;
|
||||
seenEventKeys.add(key);
|
||||
result.observedCombatEvents.push(event);
|
||||
}
|
||||
};
|
||||
const initialMobs = liveMobs(entryState).map((mob) => ({
|
||||
id: mob.id, name: mob.name, level: mob.level, position: mob.position,
|
||||
distanceFromEntrance: distance3(entryState.player.position, mob.position),
|
||||
boss: mob.boss,
|
||||
})).sort((left, right) => left.distanceFromEntrance - right.distanceFromEntrance);
|
||||
result.sceneMobsAtEntry = initialMobs;
|
||||
const plan = result.actionPlan;
|
||||
if (!plan.damage) {
|
||||
result.bindingDiagnostic = {
|
||||
actionBindings: entryState.actionBindings,
|
||||
computedActionPlan: plan,
|
||||
playerLevel: entryState.player.level,
|
||||
boundSlots: actionSlots.map(([bindingId, key]) => ({ bindingId, key,
|
||||
abilityId: entryState.actionBindings[bindingId] ?? null })),
|
||||
rawBoundAbilities: actionSlots.map(([bindingId, key]) => {
|
||||
const id = entryState.actionBindings[bindingId];
|
||||
const ability = entryState.abilities.find((candidate) => candidate.id === id);
|
||||
return { key, id, passive: ability?.passive, unlockLevel: ability?.unlockLevel,
|
||||
target: ability?.target, rawEffects: ability?.effects,
|
||||
flattenedEffects: ability ? flatEffects(ability.effects).map((effect) => effect.kind) : null };
|
||||
}),
|
||||
abilityCount: entryState.abilities.length,
|
||||
availableAbilities: entryState.abilities.filter((ability) => !ability.passive
|
||||
&& (ability.unlockLevel ?? 1) <= entryState.player.level).map((ability) => ({ id: ability.id,
|
||||
name: ability.name, target: ability.target, unlockLevel: ability.unlockLevel,
|
||||
effects: flatEffects(ability.effects).map((effect) => effect.kind) })),
|
||||
};
|
||||
fs.writeFileSync(path.join(outputDirectory, `${route.id}-binding-diagnostic.json`),
|
||||
`${JSON.stringify(result.bindingDiagnostic, null, 2)}\n`);
|
||||
throw new Error(`${route.title}: no native damage ability is bound to a normal keyboard slot. Details: ${JSON.stringify({
|
||||
actionBindings: result.bindingDiagnostic.actionBindings,
|
||||
availableAbilities: result.bindingDiagnostic.availableAbilities.slice(0, 18),
|
||||
})}`);
|
||||
}
|
||||
result.mappedHealAvailable = Boolean(plan.healing);
|
||||
|
||||
let snapshot = await readLiveState(page);
|
||||
let selected = await selectHostileWithTab(page, result, 10);
|
||||
if (selected) snapshot = selected.snapshot;
|
||||
let candidate = (selected?.target && hasFinitePosition(selected.target.position) ? selected.target : null)
|
||||
?? liveMobs(snapshot)
|
||||
.map((mob) => ({ ...mob, distance: distance3(snapshot.player.position, mob.position) }))
|
||||
.filter((mob) => mob.distance < 120 && !mob.boss && hasFinitePosition(mob.position))
|
||||
.sort((left, right) => left.distance - right.distance)[0]
|
||||
?? null;
|
||||
if (!candidate) {
|
||||
const diagnostic = await readLiveState(page, true);
|
||||
fs.writeFileSync(path.join(outputDirectory, `${route.id}-target-diagnostic.json`),
|
||||
`${JSON.stringify({ targeting: result.targeting, sceneMobsAtEntry: result.sceneMobsAtEntry,
|
||||
player: diagnostic.player, mobs: diagnostic.mobs }, null, 2)}\n`);
|
||||
throw new Error(`${route.title}: no live in-scene pack was available near the campaign entrance.`);
|
||||
}
|
||||
assert(hasFinitePosition(candidate.position), `${route.title}: candidate mob transform must be finite before movement.`);
|
||||
result.firstPackTarget = { id: candidate.id, name: candidate.name,
|
||||
position: candidate.position, transformAvailable: hasFinitePosition(candidate.position),
|
||||
distanceAtSelection: distance3(snapshot.player.position, candidate.position), boss: candidate.boss };
|
||||
|
||||
const damageRange = Number(plan.damage.range?.max ?? 40);
|
||||
let walked = 0;
|
||||
let stuckSteps = 0;
|
||||
for (let step = 0; step < 18; step += 1) {
|
||||
const current = await readLiveState(page);
|
||||
let freshTarget = current.mobs.find((mob) => mob.id === candidate.id && !mob.dead
|
||||
&& !mob.despawned && !mob.boss && hasFinitePosition(mob.position));
|
||||
if (!freshTarget) {
|
||||
freshTarget = liveMobs(current).filter((mob) => !mob.boss && hasFinitePosition(mob.position))
|
||||
.map((mob) => ({ ...mob, distance: distance3(current.player.position, mob.position) }))
|
||||
.filter((mob) => mob.distance < 120).sort((left, right) => left.distance - right.distance)[0];
|
||||
}
|
||||
if (!freshTarget) break;
|
||||
candidate = freshTarget;
|
||||
const targetPosition = freshTarget.position;
|
||||
const dist = distance3(current.player.position, targetPosition);
|
||||
if (dist <= Math.max(7, damageRange - 3) && walked >= 6) break;
|
||||
const moved = await walkToward(page, result, targetPosition, 700);
|
||||
if (!moved) break;
|
||||
walked += moved.step.planarDisplacement;
|
||||
stuckSteps = moved.step.blocked ? stuckSteps + 1 : 0;
|
||||
snapshot = moved.snapshot;
|
||||
if (stuckSteps >= 3) break;
|
||||
}
|
||||
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.walk) });
|
||||
result.keyboardTravelDistance = walked;
|
||||
result.positionAfterTraversal = snapshot.player.position;
|
||||
result.distanceTravelledAtEntry = entryState.game.distanceTravelled;
|
||||
result.distanceTravelledAfterTraversal = snapshot.game.distanceTravelled;
|
||||
|
||||
await page.keyboard.press('F1');
|
||||
result.partyCommands = [{ at: Date.now(), key: 'F1', command: 'Attack', targetId: snapshot.player.selectedTargetId }];
|
||||
await page.waitForTimeout(900);
|
||||
await page.keyboard.press('F2');
|
||||
result.partyCommands.push({ at: Date.now(), key: 'F2', command: 'Defend', reason: 'hold the initial encounter near the healer' });
|
||||
const targetAgain = await selectHostileWithTab(page, result, 6);
|
||||
if (targetAgain) {
|
||||
snapshot = targetAgain.snapshot;
|
||||
if (targetAgain.target.position) candidate = targetAgain.target;
|
||||
}
|
||||
const endAt = Date.now() + routeDurationMs;
|
||||
let lastDamageAt = 0;
|
||||
let lastHealAt = 0;
|
||||
let middleCaptured = false;
|
||||
let lastSampleAt = 0;
|
||||
let noTargetStreak = 0;
|
||||
while (Date.now() < endAt) {
|
||||
snapshot = await readLiveState(page);
|
||||
collectCombatEvents(snapshot);
|
||||
if (snapshot.player.health <= 0) break;
|
||||
if (lastSampleAt === 0 || snapshot.now - lastSampleAt >= 1_000) {
|
||||
result.samples.push({ at: snapshot.now, player: snapshot.player,
|
||||
party: snapshot.party, mobs: snapshot.mobs.map((mob) => ({ id: mob.id, name: mob.name,
|
||||
health: mob.health, maxHealth: mob.maxHealth, dead: mob.dead, engaged: mob.engaged,
|
||||
activeCast: mob.activeCast, position: mob.position })) });
|
||||
lastSampleAt = snapshot.now;
|
||||
}
|
||||
|
||||
const currentTarget = snapshot.mobs.find((mob) => mob.id === snapshot.player.selectedTargetId
|
||||
&& !mob.dead && !mob.despawned && !mob.boss && hasFinitePosition(mob.position));
|
||||
const wounded = snapshot.party.filter((member) => member.health > 0 && member.health < member.maxHealth - 1)
|
||||
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth);
|
||||
if (wounded.length && plan.healing && Date.now() - lastHealAt > 1_100) {
|
||||
let member = wounded[0];
|
||||
const healRange = Number(plan.healing.range?.max ?? 25);
|
||||
const pursuit = { at: Date.now(), memberId: member.id, memberName: member.name,
|
||||
startPosition: snapshot.player.position, startAllyPosition: member.position,
|
||||
startDistance: hasFinitePosition(member.position)
|
||||
? distance3(snapshot.player.position, member.position) : null,
|
||||
movement: [], outcome: 'position-unavailable' };
|
||||
for (let step = 0; step < 6 && !snapshot.player.activeCast; step += 1) {
|
||||
const refreshed = await readLiveState(page);
|
||||
const freshMember = refreshed.party.find((entry) => entry.id === member.id);
|
||||
if (!freshMember || freshMember.health <= 0 || !hasFinitePosition(freshMember.position)) {
|
||||
snapshot = refreshed;
|
||||
pursuit.outcome = 'member-no-longer-live-or-position-unavailable';
|
||||
break;
|
||||
}
|
||||
snapshot = refreshed;
|
||||
member = freshMember;
|
||||
const distance = distance3(snapshot.player.position, member.position);
|
||||
pursuit.lastAllyPosition = member.position;
|
||||
pursuit.lastDistance = distance;
|
||||
if (distance <= healRange - 1.5) {
|
||||
pursuit.outcome = 'within-heal-range';
|
||||
break;
|
||||
}
|
||||
const moved = await walkToward(page, result, member.position, 600);
|
||||
if (!moved || moved.step.planarDisplacement < 0.08) {
|
||||
pursuit.outcome = 'movement-blocked-or-aligned';
|
||||
break;
|
||||
}
|
||||
pursuit.movement.push(moved.step);
|
||||
snapshot = moved.snapshot;
|
||||
}
|
||||
snapshot = await readLiveState(page);
|
||||
member = snapshot.party.find((entry) => entry.id === member.id) ?? member;
|
||||
pursuit.endPosition = snapshot.player.position;
|
||||
pursuit.endAllyPosition = member.position ?? null;
|
||||
pursuit.endDistance = hasFinitePosition(member.position)
|
||||
? distance3(snapshot.player.position, member.position) : null;
|
||||
if (pursuit.endDistance !== null && pursuit.endDistance <= healRange - 1.5) {
|
||||
pursuit.outcome = 'within-heal-range';
|
||||
}
|
||||
if (pursuit.outcome !== 'within-heal-range') {
|
||||
result.healPursuits.push(pursuit);
|
||||
lastHealAt = Date.now();
|
||||
await page.waitForTimeout(200);
|
||||
continue;
|
||||
}
|
||||
const targetBefore = snapshot.player.selectedTargetId;
|
||||
await selectPartyMember(page, member);
|
||||
const cast = await performCast(page, result, plan.healing, 'healing', member.id,
|
||||
`heal-lowest-wounded-active-NPC (${Math.round(member.health / member.maxHealth * 100)}% before cast)`);
|
||||
pursuit.castActionIndex = result.actions.length - 1;
|
||||
pursuit.outcome = cast.cast.actionResolved ? 'cast-effective-heal'
|
||||
: cast.cast.actionAccepted ? 'cast-accepted-without-observed-healing' : 'cast-not-accepted';
|
||||
result.healPursuits.push(pursuit);
|
||||
lastHealAt = Date.now();
|
||||
if (!middleCaptured) {
|
||||
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.middle) });
|
||||
middleCaptured = true;
|
||||
}
|
||||
result.actions[result.actions.length - 1].hostileTargetBeforeAllySelection = targetBefore;
|
||||
if (cast.cast.actionResolved) result.effectiveNativeHeals = (result.effectiveNativeHeals ?? 0) + 1;
|
||||
noTargetStreak = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
let target = currentTarget;
|
||||
if (!target) {
|
||||
const selectedTarget = await selectHostileWithTab(page, result, 6);
|
||||
target = selectedTarget?.target ?? null;
|
||||
if (selectedTarget) snapshot = selectedTarget.snapshot;
|
||||
}
|
||||
if (!target) {
|
||||
noTargetStreak += 1;
|
||||
const available = liveMobs(snapshot).filter((mob) => !mob.boss).map((mob) => ({ ...mob,
|
||||
distance: distance3(snapshot.player.position, mob.position) }))
|
||||
.filter((mob) => mob.distance < 75 && hasFinitePosition(mob.position))
|
||||
.sort((left, right) => left.distance - right.distance)[0];
|
||||
if (available && noTargetStreak < 3) {
|
||||
const moved = await walkToward(page, result, available.position, 650);
|
||||
if (moved && moved.step.planarDisplacement > 0.1) {
|
||||
await selectHostileWithTab(page, result, 6);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(350);
|
||||
continue;
|
||||
}
|
||||
noTargetStreak = 0;
|
||||
|
||||
const targetDistance = distance3(snapshot.player.position, target.position);
|
||||
const targetRange = Math.max(0, Number(plan.damage.range?.max ?? 40) - 1.5);
|
||||
if (targetDistance > targetRange || !target.position) {
|
||||
const destination = target.position ?? (target.id === candidate.id ? candidate.position : null);
|
||||
if (!destination) {
|
||||
result.targetingWithoutPosition ??= [];
|
||||
result.targetingWithoutPosition.push({ at: snapshot.now, id: target.id, name: target.name });
|
||||
const cast = await performCast(page, result, plan.damage, 'damage', target.id,
|
||||
'keyboard damage attempt after Tab selection where mob transform was not readable');
|
||||
if (cast.cast.actionResolved) result.effectivePlayerDamageCasts = (result.effectivePlayerDamageCasts ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
const moved = await walkToward(page, result, destination, 550);
|
||||
if (moved && moved.step.planarDisplacement > 0.08) continue;
|
||||
}
|
||||
if (Date.now() - lastDamageAt > 700) {
|
||||
const cast = await performCast(page, result, plan.damage, 'damage', target.id,
|
||||
'player keyboard cast against the Tab-selected live campaign mob');
|
||||
lastDamageAt = Date.now();
|
||||
if (!middleCaptured) {
|
||||
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.middle) });
|
||||
middleCaptured = true;
|
||||
}
|
||||
if (cast.cast.actionResolved) result.effectivePlayerDamageCasts = (result.effectivePlayerDamageCasts ?? 0) + 1;
|
||||
} else {
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
}
|
||||
|
||||
const final = await readLiveState(page);
|
||||
collectCombatEvents(final);
|
||||
result.segmentEndedAt = Date.now();
|
||||
result.endingPosition = final.player.position;
|
||||
result.endingDistanceTravelled = final.game.distanceTravelled;
|
||||
result.playerAtEndpoint = final.player;
|
||||
result.partyAtEndpoint = final.party;
|
||||
result.sceneMobsAtEndpoint = final.mobs;
|
||||
const playerEvents = result.observedCombatEvents.filter(sourcePlayerEvent);
|
||||
const healedPartyIds = new Set(entryState.party.map((member) => member.id));
|
||||
const effectiveHealEvents = playerEvents.filter((event) => event.kind === 'healing'
|
||||
&& healedPartyIds.has(event.targetActorId) && event.effectiveAmount > 0);
|
||||
const effectiveDamageEvents = playerEvents.filter((event) => event.kind === 'damage' && event.effectiveAmount > 0);
|
||||
for (let index = 0; index < result.actions.length; index += 1) {
|
||||
const action = result.actions[index];
|
||||
const nextSameActionAt = result.actions.slice(index + 1)
|
||||
.find((next) => next.abilityId === action.abilityId)?.at ?? Number.POSITIVE_INFINITY;
|
||||
const actionEvents = playerEvents.filter((event) => event.abilityId === action.abilityId
|
||||
&& event.targetActorId === action.intendedTargetId
|
||||
&& event.occurredAt >= action.at - 20 && event.occurredAt < nextSameActionAt);
|
||||
const effectEvents = actionEvents.filter((event) => event.kind === action.kind);
|
||||
action.resolvedEvents = effectEvents.map((event) => ({ kind: event.kind,
|
||||
targetActorId: event.targetActorId, effectiveAmount: event.effectiveAmount,
|
||||
overheal: event.overheal, school: event.school ?? null, abilityId: event.abilityId,
|
||||
occurredAt: event.occurredAt }));
|
||||
action.actionAccepted = actionEvents.some((event) => event.kind === 'cast'
|
||||
&& ['started', 'completed'].includes(event.outcome));
|
||||
action.actionResolved = effectEvents.some((event) => event.effectiveAmount > 0);
|
||||
}
|
||||
result.effectiveNativeHeals = effectiveHealEvents.length;
|
||||
result.totalEffectiveNativeHealing = effectiveHealEvents.reduce((sum, event) => sum + event.effectiveAmount, 0);
|
||||
result.effectivePlayerDamageEvents = effectiveDamageEvents.length;
|
||||
result.totalEffectivePlayerDamage = effectiveDamageEvents.reduce((sum, event) => sum + event.effectiveAmount, 0);
|
||||
result.coverageEndpoint = {
|
||||
endpoint: final.game.areaName,
|
||||
endpointPosition: final.player.position,
|
||||
accumulatedTravelDistance: final.game.distanceTravelled - entryState.game.distanceTravelled,
|
||||
keyboardStepDisplacement: result.movement.reduce((sum, step) => sum + step.planarDisplacement, 0),
|
||||
totalKeyboardKeyDowns: result.movement.length,
|
||||
damageEvents: result.effectivePlayerDamageEvents,
|
||||
totalEffectivePlayerDamage: result.totalEffectivePlayerDamage,
|
||||
healingEvents: effectiveHealEvents.length,
|
||||
totalEffectiveNativeHealing: result.totalEffectiveNativeHealing,
|
||||
observedSchools: [...new Set(result.observedCombatEvents.map((event) => event.school).filter(Boolean))].sort(),
|
||||
eventCountsByKind: Object.fromEntries([...new Set(result.observedCombatEvents.map((event) => event.kind))]
|
||||
.map((kind) => [kind, result.observedCombatEvents.filter((event) => event.kind === kind).length])),
|
||||
killedMobNames: final.mobs.filter((mob) => mob.dead).map((mob) => mob.name),
|
||||
observedDeadMobNames: [...new Set(observedDeadMobs.values())].sort(),
|
||||
observedDeadMobCount: observedDeadMobs.size,
|
||||
partyDeaths: final.party.filter((member) => member.health <= 0).map((member) => member.name),
|
||||
};
|
||||
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.endpoint) });
|
||||
result.pageErrors = [];
|
||||
fs.writeFileSync(path.join(outputDirectory, `${route.id}-route.json`), `${JSON.stringify(result, null, 2)}\n`);
|
||||
return result;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const { createGameServer } = await import('../server/server.mjs');
|
||||
const runtime = createGameServer({ dataDir: dataDirectory, enableShelvedModes: false });
|
||||
await new Promise((resolve, reject) => {
|
||||
runtime.server.once('error', reject);
|
||||
runtime.server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = runtime.server.address();
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
page.setDefaultTimeout(120_000);
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
const completedRoutes = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() });
|
||||
});
|
||||
await page.addInitScript(({ urls }) => {
|
||||
window.nativeRouteKeyLog = [];
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'Tab', 'F1', 'Digit1', 'Digit2', 'Digit3', 'Digit4',
|
||||
'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Escape'].includes(event.code)) {
|
||||
window.nativeRouteKeyLog.push({ at: Date.now(), code: event.code, repeat: event.repeat });
|
||||
}
|
||||
}, true);
|
||||
window.nativeRouteStores = async () => {
|
||||
const modules = await Promise.all(urls.map((url) => import(url)));
|
||||
const namedExports = modules.flatMap((module) => Object.entries(module));
|
||||
const exports = namedExports.map(([, value]) => value);
|
||||
const stores = exports.filter((value) => value && typeof value.getState === 'function');
|
||||
// Vite renames this exported registry function. Match the inspected pure
|
||||
// getMobRuntimeTransform body: it only reads the private Map and copies
|
||||
// position/yaw, returning null for an unmounted actor.
|
||||
const transformExport = namedExports.find(([, value]) => {
|
||||
if (typeof value !== 'function') return false;
|
||||
const source = Function.prototype.toString.call(value);
|
||||
return source.includes('.mounted') && source.includes('.value')
|
||||
&& source.includes('position:') && source.includes('yaw:')
|
||||
&& source.includes('...') && source.includes('null');
|
||||
});
|
||||
const readTransform = transformExport?.[1] ?? null;
|
||||
// Source-mapped to getPartyRuntimePosition: it reads the party position
|
||||
// registry and returns a defensive copy or null, with no state mutation.
|
||||
const partyPositionExport = namedExports.find(([, value]) => {
|
||||
if (typeof value !== 'function') return false;
|
||||
const source = Function.prototype.toString.call(value);
|
||||
return /\.get\(/.test(source)
|
||||
&& /return\s+[A-Za-z_$][\w$]*\s*\?\s*\[\s*\.\.\.\s*[A-Za-z_$][\w$]*\s*\]\s*:\s*null/.test(source);
|
||||
});
|
||||
const readPartyPosition = partyPositionExport?.[1] ?? null;
|
||||
return {
|
||||
getMobPosition: readTransform ? (id) => readTransform(id)?.position ?? null : null,
|
||||
mobPositionExports: transformExport ? [transformExport[0]] : [],
|
||||
getPartyPosition: readPartyPosition,
|
||||
partyPositionExports: partyPositionExport ? [partyPositionExport[0]] : [],
|
||||
game: stores.find((store) => typeof store.getState()?.relocatePlayer === 'function'),
|
||||
shell: stores.find((store) => typeof store.getState()?.saveActiveProgression === 'function'),
|
||||
combat: stores.find((store) => typeof store.getState()?.castAbility === 'function'),
|
||||
party: stores.find((store) => typeof store.getState()?.setCommand === 'function'
|
||||
&& Array.isArray(store.getState()?.members)),
|
||||
};
|
||||
};
|
||||
}, { urls: moduleUrls });
|
||||
|
||||
try {
|
||||
if (moduleFiles.length < 3) throw new Error(`Expected built game assets were not found in dist/assets: ${moduleFiles.join(', ')}`);
|
||||
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
|
||||
const { profile, rosterUi } = await prepareCharacter(page);
|
||||
for (let index = 0; index < routesToRun.length; index += 1) {
|
||||
const route = routesToRun[index];
|
||||
await enterCampaignDungeon(page, route, index === 0, profile.level);
|
||||
await page.evaluate(() => { window.nativeRouteStoresRole = 'healer'; });
|
||||
const entry = await readLiveState(page, true);
|
||||
const routeResult = await runRouteSegment(page, route, entry, profile);
|
||||
routeResult.pageErrors = pageErrors.slice();
|
||||
routeResult.consoleErrors = consoleErrors.slice();
|
||||
routeResult.keyboardEvents = await page.evaluate(() => window.nativeRouteKeyLog.slice(-500));
|
||||
fs.writeFileSync(path.join(outputDirectory, `${route.id}-route.json`), `${JSON.stringify(routeResult, null, 2)}\n`);
|
||||
completedRoutes.push(routeResult);
|
||||
}
|
||||
assert.deepEqual(pageErrors, [], `No browser page exceptions: ${JSON.stringify(pageErrors)}`);
|
||||
fs.writeFileSync(path.join(outputDirectory, 'results.json'), `${JSON.stringify({
|
||||
status: 'complete',
|
||||
snapshotKind: baseline ? 'baseline-existing-dist; final-build rerun required' : 'final-build route QA',
|
||||
buildAssets: moduleFiles,
|
||||
routeDurationMs,
|
||||
profile,
|
||||
rosterUi,
|
||||
routes: completedRoutes.map((route) => ({
|
||||
dungeonId: route.dungeonId,
|
||||
coverage: route.coverage,
|
||||
coverageEndpoint: route.coverageEndpoint,
|
||||
mappedHealAvailable: route.mappedHealAvailable,
|
||||
effectiveNativeHeals: route.effectiveNativeHeals ?? 0,
|
||||
totalEffectiveNativeHealing: route.totalEffectiveNativeHealing ?? 0,
|
||||
effectivePlayerDamageEvents: route.effectivePlayerDamageEvents ?? 0,
|
||||
totalEffectivePlayerDamage: route.totalEffectivePlayerDamage ?? 0,
|
||||
coverageEndpoint: route.coverageEndpoint,
|
||||
})),
|
||||
pageErrors,
|
||||
consoleErrors,
|
||||
}, null, 2)}\n`);
|
||||
console.log(`Native campaign route QA ${baseline ? 'baseline' : 'complete'}: ${outputDirectory}`);
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(outputDirectory, 'failure.png') }).catch(() => {});
|
||||
const failed = { status: 'failed', snapshotKind: baseline ? 'baseline-existing-dist' : 'final-build route QA',
|
||||
buildAssets: moduleFiles, error: error.stack ?? error.message, pageErrors, consoleErrors,
|
||||
bodyText: await page.locator('body').innerText().catch(() => '') };
|
||||
fs.writeFileSync(path.join(outputDirectory, 'failure.json'), `${JSON.stringify(failed, null, 2)}\n`);
|
||||
console.error(failed.bodyText.slice(-2_500));
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
const target = fs.realpathSync(dataDirectory);
|
||||
if (path.dirname(target) !== fs.realpathSync(os.tmpdir())
|
||||
|| !path.basename(target).startsWith('healer-man-native-routes-')) {
|
||||
throw new Error('Unsafe temporary playtest database cleanup path.');
|
||||
}
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,116 @@
|
||||
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 dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-equipment-'));
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
|
||||
async function capture(page, name) {
|
||||
await page.screenshot({ path: path.join(output, `${name}.png`) });
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const { createGameServer } = await import('../server/server.mjs');
|
||||
const runtime = createGameServer({ dataDir, staticDir: path.resolve('dist'), contentDir: path.resolve('content'), enableShelvedModes: false });
|
||||
await new Promise((resolve) => runtime.server.listen(0, '127.0.0.1', resolve));
|
||||
console.log({ cwd: process.cwd(), staticDir: runtime.staticDir, indexExists: fs.existsSync(path.join(runtime.staticDir, 'index.html')), port: runtime.server.address().port });
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const results = {};
|
||||
try {
|
||||
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();
|
||||
page.setDefaultTimeout(60000);
|
||||
const errors = [];
|
||||
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());
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400 && !response.url().endsWith('/favicon.ico')) errors.push(`${response.status()}: ${response.url()}`);
|
||||
});
|
||||
try {
|
||||
await page.goto(`http://127.0.0.1:${runtime.server.address().port}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('button', { name: /Play offline/ }).click();
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.creator-category-tabs button').filter({ hasText: category }).click();
|
||||
await page.locator('.class-option').filter({ hasText: 'Priest' }).click();
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name', { exact: true }).fill(category === 'WoW' ? 'Wowgear' : 'Romgear');
|
||||
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 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-role-option--healer').click();
|
||||
await page.locator('.scene-shell').waitFor();
|
||||
await page.locator('.loading-screen').waitFor({ state: 'hidden' });
|
||||
await page.locator('.party-frames').waitFor();
|
||||
await page.waitForTimeout(12000);
|
||||
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden' });
|
||||
await capture(page, `${category}-dungeon`);
|
||||
// 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);
|
||||
await page.mouse.down({ button: 'right' });
|
||||
await page.mouse.move(bounds.x + bounds.width * 0.5 + 440, bounds.y + bounds.height * 0.55, { steps: 40 });
|
||||
await page.mouse.up({ button: 'right' });
|
||||
await page.waitForTimeout(1200);
|
||||
await capture(page, `${category}-party-front`);
|
||||
if (category === 'RoM') {
|
||||
await page.locator('.scene-shell').focus();
|
||||
await page.keyboard.down('s');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.keyboard.up('s');
|
||||
await page.mouse.wheel(700, 700);
|
||||
await page.waitForTimeout(2500);
|
||||
await capture(page, `${category}-party-open`);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
results[category] = {
|
||||
installedNativeCharacterPack: true,
|
||||
screenshots: [`${category}-roster.png`, `${category}-dungeon.png`, `${category}-party-front.png`,
|
||||
...(category === 'RoM' ? [`${category}-party-open.png`] : [])],
|
||||
errors,
|
||||
};
|
||||
} catch (error) {
|
||||
await capture(page, `${category}-failure`);
|
||||
console.error((await page.locator('body').innerText()).slice(-2500));
|
||||
console.error(errors);
|
||||
throw error;
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(path.join(output, 'results.json'), `${JSON.stringify(results, null, 2)}\n`);
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
assert.equal(path.dirname(path.resolve(dataDir)), path.resolve(os.tmpdir()));
|
||||
assert.ok(path.basename(dataDir).startsWith('healer-man-equipment-'));
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,172 @@
|
||||
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 os = require('node:os');
|
||||
|
||||
const label = process.env.PERFORMANCE_LABEL || 'current';
|
||||
const output = path.resolve('artifacts/playtest/performance', label);
|
||||
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 page.locator('.dungeon-role-option--healer').click();
|
||||
await page.locator('.scene-shell').waitFor();
|
||||
await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 180_000 });
|
||||
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 180_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
async function measure(page, cdp, label, seconds = 10) {
|
||||
const read = async () => {
|
||||
const { metrics } = await cdp.send('Performance.getMetrics');
|
||||
return Object.fromEntries(metrics.map(({ name, value }) => [name, value]));
|
||||
};
|
||||
const before = await read();
|
||||
const drawsBefore = await page.evaluate(() => window.__performanceDraws);
|
||||
await page.waitForTimeout(seconds * 1000);
|
||||
const after = await read();
|
||||
const drawsAfter = await page.evaluate(() => window.__performanceDraws);
|
||||
await cdp.send('HeapProfiler.collectGarbage');
|
||||
const heap = await cdp.send('Runtime.getHeapUsage');
|
||||
const elapsed = after.Timestamp - before.Timestamp;
|
||||
const sample = {
|
||||
label,
|
||||
seconds: elapsed,
|
||||
mainThreadBusyPercent: (after.TaskDuration - before.TaskDuration) / elapsed * 100,
|
||||
scriptPercent: (after.ScriptDuration - before.ScriptDuration) / elapsed * 100,
|
||||
layoutCount: after.LayoutCount - before.LayoutCount,
|
||||
drawCallsPerSecond: (drawsAfter - drawsBefore) / elapsed,
|
||||
heapMiB: heap.usedSize / 1048576,
|
||||
backingStorageMiB: (heap.backingStorageSize || 0) / 1048576,
|
||||
nodes: after.Nodes,
|
||||
documents: after.Documents,
|
||||
scene: await page.locator('.scene-shell').evaluateAll((elements) => elements.map((element) => ({
|
||||
frameLoop: element.dataset.frameLoop,
|
||||
performance: document.documentElement.dataset.gamePerformance || null,
|
||||
}))),
|
||||
};
|
||||
console.log(JSON.stringify(sample));
|
||||
return sample;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const { createGameServer } = await import('../server/server.mjs');
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-performance-'));
|
||||
const runtime = createGameServer({ dataDir, enableShelvedModes: false });
|
||||
await new Promise((resolve) => runtime.server.listen(4191, '127.0.0.1', resolve));
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
page.setDefaultTimeout(60_000);
|
||||
const errors = [];
|
||||
const samples = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Performance.enable');
|
||||
await cdp.send('HeapProfiler.enable');
|
||||
await page.addInitScript(() => {
|
||||
window.__performanceDraws = 0;
|
||||
for (const api of [WebGLRenderingContext, WebGL2RenderingContext]) {
|
||||
for (const name of ['drawArrays', 'drawElements', 'drawArraysInstanced', 'drawElementsInstanced']) {
|
||||
const original = api.prototype[name];
|
||||
if (!original) continue;
|
||||
api.prototype[name] = function (...args) {
|
||||
window.__performanceDraws += 1;
|
||||
return original.apply(this, args);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
await page.goto(process.env.PERFORMANCE_URL || 'http://127.0.0.1:4191', { waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('button', { name: /Play offline/ }).click();
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill('PerformanceQA');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Continue|Enter world/ }).first().evaluate((button) => button.click());
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
samples.push(await measure(page, cdp, 'menu-cold', 5));
|
||||
for (let cycle = 1; cycle <= 3; cycle += 1) {
|
||||
await enterDungeon(page);
|
||||
if (cycle === 1) {
|
||||
const renderer = await page.locator('.scene-shell canvas').first().evaluate((canvas) => {
|
||||
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||
const extension = gl?.getExtension('WEBGL_debug_renderer_info');
|
||||
return { renderer: extension ? gl.getParameter(extension.UNMASKED_RENDERER_WEBGL) : null,
|
||||
width: canvas.width, height: canvas.height };
|
||||
});
|
||||
console.log(JSON.stringify(renderer));
|
||||
fs.writeFileSync(path.join(output, 'renderer.json'), `${JSON.stringify(renderer, null, 2)}\n`);
|
||||
samples.push(await measure(page, cdp, 'active-idle'));
|
||||
await page.screenshot({ path: path.join(output, 'active.png') });
|
||||
await cdp.send('Profiler.enable');
|
||||
await cdp.send('Profiler.start');
|
||||
await page.keyboard.down('w');
|
||||
await page.waitForTimeout(3000);
|
||||
await page.keyboard.up('w');
|
||||
await page.waitForTimeout(5000);
|
||||
const { profile } = await cdp.send('Profiler.stop');
|
||||
fs.writeFileSync(path.join(output, 'active.cpuprofile'), JSON.stringify(profile));
|
||||
samples.push(await measure(page, cdp, 'after-movement', 5));
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('dialog', { name: 'Paused' }).waitFor();
|
||||
await page.waitForTimeout(1500);
|
||||
if (cycle === 3) {
|
||||
await cdp.send('Profiler.enable');
|
||||
await cdp.send('Profiler.start');
|
||||
}
|
||||
samples.push(await measure(page, cdp, `paused-cycle-${cycle}`));
|
||||
if (cycle === 3) {
|
||||
const { profile } = await cdp.send('Profiler.stop');
|
||||
fs.writeFileSync(path.join(output, 'paused.cpuprofile'), JSON.stringify(profile));
|
||||
await page.screenshot({ path: path.join(output, 'paused-cycle-3.png') });
|
||||
samples.push(await measure(page, cdp, 'paused-cycle-3-settled', 20));
|
||||
}
|
||||
if (cycle === 1) {
|
||||
await page.screenshot({ path: path.join(output, 'paused.png') });
|
||||
samples.push(await measure(page, cdp, 'paused-retention', 20));
|
||||
await page.getByRole('button', { name: 'Resume game' }).click();
|
||||
await page.waitForTimeout(1500);
|
||||
samples.push(await measure(page, cdp, 'resumed', 5));
|
||||
await page.screenshot({ path: path.join(output, 'resumed.png') });
|
||||
// Exercise the app's visibility handlers deterministically. This is a
|
||||
// synthetic lifecycle check, not a measurement of OS tab throttling.
|
||||
await page.evaluate(() => {
|
||||
window.__profileHidden = true;
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => window.__profileHidden ? 'hidden' : 'visible' });
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => window.__profileHidden });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
samples.push(await measure(page, cdp, 'synthetic-hidden', 5));
|
||||
await page.evaluate(() => {
|
||||
window.__profileHidden = false;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('dialog', { name: 'Paused' }).waitFor();
|
||||
}
|
||||
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
await page.waitForTimeout(2000);
|
||||
samples.push(await measure(page, cdp, `menu-cycle-${cycle}`, 5));
|
||||
}
|
||||
fs.writeFileSync(path.join(output, 'results.json'), `${JSON.stringify({ samples, errors }, null, 2)}\n`);
|
||||
if (errors.length) throw new Error(errors.join('\n'));
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(output, 'failure.png') });
|
||||
fs.writeFileSync(path.join(output, 'results.json'), `${JSON.stringify({ samples, errors }, null, 2)}\n`);
|
||||
console.error((await page.locator('body').innerText()).slice(-2000));
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
const resolved = path.resolve(dataDir);
|
||||
if (!resolved.startsWith(path.resolve(os.tmpdir()) + path.sep)) throw new Error('Unexpected test directory');
|
||||
fs.rmSync(resolved, { recursive: true, force: true });
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -276,7 +276,7 @@ async function readStatsPanelPage(page) {
|
||||
}));
|
||||
return {
|
||||
...stats,
|
||||
caption: await page.locator('.equipment-character-stats__note').innerText().catch(() => ''),
|
||||
caption: await page.locator('.equipment-character-stats__note').first().innerText().catch(() => ''),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -509,10 +509,19 @@ async function readStatsPanelPage(page) {
|
||||
assert.ok(nativeRuntime.inventory.some((item) => item.instanceId === 'qa-robes-native'));
|
||||
assert.equal(nativeRuntime.playerEquipment.chest, 'qa-robes-native', 'The equipped test item reaches the actual dungeon runtime.');
|
||||
for (const member of nativeRuntime.party) {
|
||||
if (member.classId.startsWith('rom-')) assert.equal(member.statRuleset, 'rom', `${member.name} keeps its own RoM ruleset.`);
|
||||
if (member.classId.startsWith('rom-')) {
|
||||
assert.equal(member.statRuleset, 'rom', `${member.name} keeps its own RoM ruleset.`);
|
||||
assert.ok(['rom-knight', 'rom-priest', 'rom-rogue', 'rom-scout', 'rom-mage'].includes(member.classId),
|
||||
'Ordinary generated RoM companions stay inside the active five classes.');
|
||||
}
|
||||
}
|
||||
results.observations.nativeDungeonRuntime = nativeRuntime;
|
||||
await openStatsPanel(page);
|
||||
const trainingGearText = await page.locator('.equipment-loadout').innerText();
|
||||
assert.match(trainingGearText, /Training gear scales automatically through level 79\./);
|
||||
assert.match(trainingGearText, /Training gear 30/i);
|
||||
assert.doesNotMatch(trainingGearText, /\bHeirloom\b/i, 'RoM automatic gear uses training labels, not authentic heirloom claims.');
|
||||
results.observations.romTrainingGearLabels = true;
|
||||
const nativePanelPages = {};
|
||||
for (const [index, pageName] of ['attributes', 'offense', 'defense'].entries()) {
|
||||
if (pageName !== 'attributes') {
|
||||
@@ -528,7 +537,7 @@ async function readStatsPanelPage(page) {
|
||||
const labels = new Set(panelPage.rows.map((row) => row.label));
|
||||
const missing = requiredNativeRows[pageName].filter((label) => !labels.has(label));
|
||||
assert.deepEqual(missing, [], `Native RoM ${pageName} page is missing fields: ${missing.join(', ')}`);
|
||||
assert.equal(panelPage.caption, 'Native RoM stats · shared dungeon combat rules');
|
||||
assert.equal(panelPage.caption, 'Native RoM stats');
|
||||
nativePanelPages[pageName] = panelPage;
|
||||
results.screenshots[`nativeStats${pageName[0].toUpperCase()}${pageName.slice(1)}Desktop`] = await capture(
|
||||
page,
|
||||
@@ -560,6 +569,32 @@ async function readStatsPanelPage(page) {
|
||||
assert.ok(nativePanelPages.attributes.vitals.some((row) => row.includes(`Combat health ${Math.round(nativeRuntime.maxHealth).toLocaleString()}`)));
|
||||
results.observations.nativePanelPages = { ...nativePanelPages, renderedMeters };
|
||||
await page.locator('.equipment-stat-tabs').getByRole('tab', { name: 'attributes', exact: true }).click();
|
||||
const npcMember = nativeRuntime.party.find((member) => member.classId === 'rom-mage')
|
||||
?? nativeRuntime.party.find((member) => member.classId.startsWith('rom-') && member.statRuleset === 'rom');
|
||||
assert.ok(npcMember, 'The live party contains a native RoM companion owner to inspect.');
|
||||
const npcOwnerButton = page.locator('.equipment-panel__owners .equipment-owner').filter({ hasText: npcMember.name });
|
||||
await npcOwnerButton.waitFor();
|
||||
await npcOwnerButton.click();
|
||||
await page.waitForFunction((name) => (
|
||||
document.querySelector('.equipment-loadout > header strong')?.textContent?.trim() === name
|
||||
), npcMember.name);
|
||||
const npcNativeStats = await readStatsPanelPage(page);
|
||||
const npcNativeLabels = new Set(npcNativeStats.rows.map((row) => row.label));
|
||||
const missingNpcRows = requiredNativeRows.attributes.filter((label) => !npcNativeLabels.has(label));
|
||||
assert.deepEqual(missingNpcRows, [], `Native RoM NPC attributes page is missing fields: ${missingNpcRows.join(', ')}`);
|
||||
assert.equal(npcNativeStats.caption, 'Native RoM stats');
|
||||
const npcTrainingGearText = await page.locator('.equipment-loadout').innerText();
|
||||
assert.match(npcTrainingGearText, /Training gear scales automatically through level 79\./);
|
||||
assert.match(npcTrainingGearText, /Training gear 30/i);
|
||||
assert.doesNotMatch(npcTrainingGearText, /\bHeirloom\b/i, 'RoM NPC automatic gear uses training labels.');
|
||||
assert.equal(npcMember?.statRuleset, 'rom', 'The selected NPC owner keeps its own native ruleset.');
|
||||
results.observations.nativeNpcOwnerPanel = { member: npcMember, stats: npcNativeStats };
|
||||
results.screenshots.nativeNpcStatsAttributesDesktop = await capture(page, '05-native-npc-stats-attributes-desktop');
|
||||
const playerOwnerButton = page.locator('.equipment-panel__owners .equipment-owner').filter({ hasText: nativeCreated.name });
|
||||
await playerOwnerButton.click();
|
||||
await page.waitForFunction((name) => (
|
||||
document.querySelector('.equipment-loadout > header strong')?.textContent?.trim() === name
|
||||
), nativeCreated.name);
|
||||
await page.locator('.equipment-panel .equipment-character-stats').scrollIntoViewIfNeeded();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForTimeout(350);
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
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');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
// UI-driven, final-build Boss Testing proof for Vow of Silence against the
|
||||
// source-scheduled Lady Anacondra Lightning Bolt. Store access is read-only
|
||||
// during combat; only the isolated profile's level and secondary class are set.
|
||||
const artifactRoot = path.resolve('playtest-artifacts/vow-live-final');
|
||||
const stamp = new Date().toISOString().replace(/[-:.]/g, '').replace('T', '-').replace('Z', '');
|
||||
const outputDirectory = path.join(artifactRoot, `final-build-${stamp}`);
|
||||
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-vow-live-final-'));
|
||||
const liveFightBudgetMs = 80_000;
|
||||
const vowSilenceWindowMs = 8_400;
|
||||
const actionSlots = [
|
||||
['primary:face-bottom', '1'], ['primary:face-left', '2'], ['primary:face-top', '3'],
|
||||
['primary:face-right', '4'], ['primary:r1', '5'], ['primary:r2', '6'],
|
||||
['primary:dpad-left', '7'], ['primary:dpad-right', '8'],
|
||||
];
|
||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
|
||||
function flatEffects(effects = []) {
|
||||
return effects.flatMap((effect) => effect.kind === 'composite' ? flatEffects(effect.effects) : [effect]);
|
||||
}
|
||||
|
||||
function actionPlanFromSnapshot(snapshot, vowId) {
|
||||
const abilities = new Map(snapshot.abilities.map((ability) => [ability.id, ability]));
|
||||
const bound = actionSlots.flatMap(([bindingId, key]) => {
|
||||
const id = snapshot.actionBindings[bindingId];
|
||||
const ability = id ? abilities.get(id) : null;
|
||||
return ability && ability.id !== vowId && !ability.passive
|
||||
&& (ability.unlockLevel ?? 1) <= snapshot.player.level
|
||||
? [{ key, ability, effects: flatEffects(ability.effects) }] : [];
|
||||
});
|
||||
const healing = bound.find(({ ability, effects }) => ability.target === 'friendly'
|
||||
&& effects.some((effect) => effect.kind === 'heal')) ?? null;
|
||||
const damage = bound.filter(({ ability, effects }) => ability.target === 'hostile'
|
||||
&& effects.some((effect) => effect.kind === 'damage'))
|
||||
.sort((left, right) => Number(right.ability.range?.max ?? 0) - Number(left.ability.range?.max ?? 0))[0] ?? null;
|
||||
return {
|
||||
healing: healing ? { key: healing.key, id: healing.ability.id, name: healing.ability.name,
|
||||
range: healing.ability.range ?? null } : null,
|
||||
damage: damage ? { key: damage.key, id: damage.ability.id, name: damage.ability.name,
|
||||
range: damage.ability.range ?? null } : null,
|
||||
bound: bound.map(({ key, ability }) => ({ key, id: ability.id, name: ability.name,
|
||||
target: ability.target, range: ability.range ?? null })),
|
||||
};
|
||||
}
|
||||
|
||||
async function readLiveState(page, includeActionSetup = false) {
|
||||
return page.evaluate((includeSetup) => window.vowFinalStores().then(({ game, combat, party,
|
||||
getMobPosition, getMobTransform, getPartyPosition }) => {
|
||||
const gameState = game.getState();
|
||||
const combatState = combat.getState();
|
||||
return {
|
||||
now: Date.now(),
|
||||
game: {
|
||||
activeDungeonId: gameState.activeDungeonId,
|
||||
gameMode: gameState.gameMode,
|
||||
gmTestMode: gameState.gmTestMode,
|
||||
bossTest: gameState.bossTest,
|
||||
assetStatus: gameState.assetStatus,
|
||||
activeSpawn: gameState.activeSpawn,
|
||||
playerPosition: gameState.playerPosition,
|
||||
cameraYaw: gameState.cameraYaw ?? null,
|
||||
overlay: gameState.overlay,
|
||||
dungeonIntroActive: gameState.dungeonIntroActive,
|
||||
},
|
||||
player: {
|
||||
classId: combatState.classId,
|
||||
secondaryClassId: combatState.secondaryClassId,
|
||||
statRuleset: combatState.statRuleset,
|
||||
level: combatState.level,
|
||||
experience: combatState.xp,
|
||||
health: combatState.health,
|
||||
maxHealth: combatState.maxHealth,
|
||||
resource: combatState.resource,
|
||||
maxResource: combatState.maxResource,
|
||||
resourceType: combatState.resourceType,
|
||||
resourceName: combatState.resourceName,
|
||||
position: gameState.playerPosition,
|
||||
cameraYaw: gameState.cameraYaw ?? null,
|
||||
selectedTargetId: combatState.selectedTargetId,
|
||||
activeCast: combatState.activeCast?.abilityId ?? null,
|
||||
globalCooldownEndsAt: combatState.globalCooldownEndsAt,
|
||||
cooldowns: combatState.cooldowns,
|
||||
},
|
||||
party: party.getState().members.map((member) => ({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
classId: member.classId,
|
||||
secondaryClassId: member.secondaryClassId,
|
||||
role: member.role,
|
||||
statRuleset: member.statRuleset,
|
||||
health: member.health,
|
||||
maxHealth: member.maxHealth,
|
||||
resource: member.resource,
|
||||
maxResource: member.maxResource,
|
||||
resourceType: member.resourceType,
|
||||
status: member.status,
|
||||
activeCast: member.activeCast?.abilityId ?? null,
|
||||
position: getPartyPosition?.(member.id) ?? null,
|
||||
})),
|
||||
mobs: Object.values(combatState.mobs).filter((mob) => !mob.despawned).map((mob) => ({
|
||||
id: mob.id,
|
||||
name: mob.name,
|
||||
serverEntry: mob.serverEntry,
|
||||
runewakerNpcId: mob.runewakerNpcId ?? null,
|
||||
creatureFamily: mob.creatureFamily,
|
||||
level: mob.level,
|
||||
health: mob.health,
|
||||
maxHealth: mob.maxHealth,
|
||||
dead: mob.dead,
|
||||
diedAt: mob.diedAt ?? null,
|
||||
engaged: mob.engaged,
|
||||
hasLoot: mob.hasLoot,
|
||||
pendingLootCount: mob.pendingLoot?.length ?? 0,
|
||||
position: getMobPosition?.(mob.id) ?? mob.position ?? mob.homePosition,
|
||||
transform: getMobTransform?.(mob.id) ?? null,
|
||||
attackRevision: mob.attackRevision,
|
||||
abilityCastCounts: mob.abilityCastCounts,
|
||||
abilityReadyAt: mob.abilityReadyAt,
|
||||
schoolLockedUntil: mob.schoolLockedUntil,
|
||||
mechanicImmunities: mob.mechanicImmunities,
|
||||
schoolImmunities: mob.schoolImmunities,
|
||||
auras: mob.auras,
|
||||
activeCast: mob.activeCast ? {
|
||||
name: mob.activeCast.name,
|
||||
attackId: mob.activeCast.attackId,
|
||||
spellId: mob.activeCast.spellId ?? null,
|
||||
school: mob.activeCast.school,
|
||||
startedAt: mob.activeCast.startedAt,
|
||||
completesAt: mob.activeCast.completesAt,
|
||||
interruptible: mob.activeCast.interruptible,
|
||||
} : null,
|
||||
statuses: mob.statuses,
|
||||
attacks: mob.attacks.map((attack) => ({ id: attack.id, name: attack.name,
|
||||
spellId: attack.spellId ?? null, castTimeMs: attack.castTimeMs ?? 0,
|
||||
cooldownMs: attack.cooldownMs ?? 0, school: attack.school,
|
||||
interruptible: attack.interruptible !== false })),
|
||||
boss: mob.boss,
|
||||
})),
|
||||
...(includeSetup ? { abilities: combatState.abilities, actionBindings: combatState.actionBindings } : {}),
|
||||
feedback: combatState.feedback ? { kind: combatState.feedback.kind,
|
||||
message: combatState.feedback.message, abilityId: combatState.feedback.abilityId ?? null,
|
||||
targetId: combatState.feedback.targetId ?? null } : null,
|
||||
events: combatState.combatEvents.slice(-200),
|
||||
presentationEvents: window.vowFinalPresentationEvents.slice(-500),
|
||||
presentationSubscriptionFound: window.vowFinalPresentationSubscriptionFound ?? false,
|
||||
presentationSubscribeExportName: window.vowFinalPresentationSubscribeExportName ?? null,
|
||||
presentationExportNames: window.vowFinalPresentationExportNames ?? [],
|
||||
presentationSubscriptionCandidates: window.vowFinalPresentationSubscriptionCandidates ?? [],
|
||||
};
|
||||
}), includeActionSetup);
|
||||
}
|
||||
|
||||
async function assignVowThroughAbilityControls(page, outputDirectory) {
|
||||
const before = await readLiveState(page, true);
|
||||
const vow = before.abilities.find((ability) => ability.dbcSpellId === 490275);
|
||||
if (!vow) throw new Error('The UI-created Priest/Knight did not learn DBC spell 490275 at the prepared level.');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
await page.getByRole('button', { name: 'Ability controls' }).click();
|
||||
await page.locator('.ability-bindings__diagram').waitFor({ timeout: 15_000 });
|
||||
await page.locator('.ability-bindings__layers button').nth(1).click();
|
||||
await page.locator('button[aria-label*="Shift+5:"]').click();
|
||||
await page.getByRole('button', { name: new RegExp(`^${vow.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:,|$)`) }).click();
|
||||
await page.screenshot({ path: path.join(outputDirectory, 'vow-binding-ui.png') });
|
||||
const after = await readLiveState(page, true);
|
||||
const binding = Object.entries(after.actionBindings).find(([, id]) => id === vow.id)?.[0] ?? null;
|
||||
await page.getByRole('button', { name: 'Close button assignments' }).click();
|
||||
await page.getByRole('button', { name: 'Resume game' }).click();
|
||||
assert.equal(binding, 'secondary:r1', 'Vow is bound through the UI to Shift+Digit5.');
|
||||
return { id: vow.id, name: vow.name, dbcSpellId: vow.dbcSpellId,
|
||||
unlockLevel: vow.unlockLevel, playerLevel: after.player.level, target: vow.target,
|
||||
range: vow.range ?? null, radius: vow.radius ?? null, targeting: vow.targeting ?? null,
|
||||
castMode: vow.castMode, castTimeMs: vow.castTimeMs, cooldownMs: vow.cooldownMs,
|
||||
gcdMs: vow.gcdMs, cost: vow.cost, effects: vow.effects,
|
||||
binding, input: 'Shift+Digit5' };
|
||||
}
|
||||
|
||||
async function selectBossWithTab(page, bossId, attempts = 8) {
|
||||
let snapshot = await readLiveState(page);
|
||||
for (let attempt = 0; attempt < attempts && snapshot.player.selectedTargetId !== bossId; attempt += 1) {
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(70);
|
||||
snapshot = await readLiveState(page);
|
||||
}
|
||||
return { selected: snapshot.player.selectedTargetId === bossId, snapshot };
|
||||
}
|
||||
|
||||
async function selectPartyMember(page, member) {
|
||||
const label = new RegExp(`^Target ${member.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')},`);
|
||||
await page.getByRole('button', { name: label }).click();
|
||||
}
|
||||
|
||||
async function selectPlayerFrame(page, playerName) {
|
||||
const label = new RegExp(`^Target ${playerName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}, you$`);
|
||||
await page.getByRole('button', { name: label }).click();
|
||||
}
|
||||
|
||||
async function performCast(page, action, kind, intendedTargetId, reason) {
|
||||
const before = await readLiveState(page);
|
||||
await page.keyboard.press(action.key);
|
||||
const startedAt = Date.now();
|
||||
let after = await readLiveState(page);
|
||||
const eventsFor = (state) => state.events.filter((event) => event.occurredAt >= before.now - 1
|
||||
&& event.abilityId === action.id && event.sourceActorId === '__player__'
|
||||
&& (kind === 'damage' ? event.kind === 'damage' : event.kind === 'healing'));
|
||||
while (Date.now() - startedAt < 2_600) {
|
||||
if (eventsFor(after).some((event) => event.effectiveAmount > 0)
|
||||
|| (!after.player.activeCast && Date.now() - startedAt >= 650)) break;
|
||||
await page.waitForTimeout(120);
|
||||
after = await readLiveState(page);
|
||||
}
|
||||
const memberBefore = before.party.find((member) => member.id === intendedTargetId);
|
||||
const memberAfter = after.party.find((member) => member.id === intendedTargetId);
|
||||
return {
|
||||
at: before.now,
|
||||
key: action.key,
|
||||
abilityId: action.id,
|
||||
abilityName: action.name,
|
||||
kind,
|
||||
reason,
|
||||
intendedTargetId,
|
||||
selectedTargetIdBefore: before.player.selectedTargetId,
|
||||
targetHealthBefore: kind === 'healing'
|
||||
? memberBefore?.health ?? (intendedTargetId === '__player__' ? before.player.health : null)
|
||||
: before.mobs.find((mob) => mob.id === intendedTargetId)?.health ?? null,
|
||||
targetHealthAfter: kind === 'healing'
|
||||
? memberAfter?.health ?? (intendedTargetId === '__player__' ? after.player.health : null)
|
||||
: after.mobs.find((mob) => mob.id === intendedTargetId)?.health ?? null,
|
||||
resolvedEvents: eventsFor(after),
|
||||
actionResolved: eventsFor(after).some((event) => event.effectiveAmount > 0),
|
||||
playerHealthBefore: before.player.health,
|
||||
playerHealthAfter: after.player.health,
|
||||
playerResourceBefore: before.player.resource,
|
||||
playerResourceAfter: after.player.resource,
|
||||
feedback: after.feedback,
|
||||
atAfter: after.now,
|
||||
};
|
||||
}
|
||||
|
||||
function playerVitals(snapshot) {
|
||||
return { health: snapshot.player.health, maxHealth: snapshot.player.maxHealth,
|
||||
resource: snapshot.player.resource, maxResource: snapshot.player.maxResource,
|
||||
resourceType: snapshot.player.resourceType, resourceName: snapshot.player.resourceName };
|
||||
}
|
||||
|
||||
function partyVitals(snapshot) {
|
||||
return snapshot.party.map((member) => ({ id: member.id, name: member.name,
|
||||
classId: member.classId, secondaryClassId: member.secondaryClassId, role: member.role,
|
||||
statRuleset: member.statRuleset, status: member.status,
|
||||
health: member.health, maxHealth: member.maxHealth, resource: member.resource,
|
||||
maxResource: member.maxResource, resourceType: member.resourceType }));
|
||||
}
|
||||
|
||||
async function runFight(page, result, vowSetup, initial) {
|
||||
const bossId = initial.game.bossTest.runtimeIds[0];
|
||||
let current = initial;
|
||||
const target = await selectBossWithTab(page, bossId);
|
||||
if (!target.selected) throw new Error('Boss Testing UI targeting did not select the live Lady Anacondra.');
|
||||
current = target.snapshot;
|
||||
await page.keyboard.press('F1');
|
||||
result.fight.startedAt = Date.now();
|
||||
result.fight.partyAttackInput = { at: result.fight.startedAt, key: 'F1', command: 'Attack', targetId: bossId };
|
||||
const wallDeadline = result.fight.startedAt + liveFightBudgetMs;
|
||||
let vowAttempts = 0;
|
||||
let nextDamageAt = 0;
|
||||
let nextHealAt = 0;
|
||||
let observedCastCountAtWindowStart = null;
|
||||
let lastSampleAt = 0;
|
||||
let lastPresentationCursor = current.presentationEvents.at(-1)?.id ?? 0;
|
||||
let previousCastIdentity = null;
|
||||
|
||||
while (Date.now() < wallDeadline) {
|
||||
current = await readLiveState(page);
|
||||
const boss = current.mobs.find((mob) => mob.id === bossId);
|
||||
if (!boss || boss.dead || boss.health <= 0) {
|
||||
result.fight.outcome = boss?.dead || boss?.health <= 0 ? 'boss-defeated' : 'boss-despawned';
|
||||
result.fight.bossAtEnd = boss ?? null;
|
||||
break;
|
||||
}
|
||||
if (current.player.health <= 0) { result.fight.outcome = 'player-dead'; break; }
|
||||
if (lastSampleAt === 0 || current.now - lastSampleAt >= 350) {
|
||||
result.fight.samples.push({ at: current.now, bossHealth: boss.health, bossCast: boss.activeCast,
|
||||
lightningBoltCount: boss.abilityCastCounts?.['spell-9532'] ?? null,
|
||||
natureLockedUntil: boss.schoolLockedUntil?.nature ?? 0,
|
||||
player: playerVitals(current), party: partyVitals(current) });
|
||||
lastSampleAt = current.now;
|
||||
}
|
||||
for (const event of current.presentationEvents) {
|
||||
if (event.id <= lastPresentationCursor) continue;
|
||||
result.fight.presentationEvents.push(event);
|
||||
lastPresentationCursor = Math.max(lastPresentationCursor, event.id);
|
||||
}
|
||||
|
||||
if (current.player.selectedTargetId !== bossId) {
|
||||
const selected = await selectBossWithTab(page, bossId, 4);
|
||||
if (selected.selected) current = selected.snapshot;
|
||||
}
|
||||
|
||||
const liveBoss = current.mobs.find((mob) => mob.id === bossId);
|
||||
const liveCast = liveBoss?.activeCast;
|
||||
const castIdentity = liveCast ? `${liveCast.attackId}:${liveCast.startedAt}` : null;
|
||||
if (castIdentity !== previousCastIdentity) {
|
||||
result.fight.castTimeline.push({ at: current.now, activeCast: liveCast });
|
||||
previousCastIdentity = castIdentity;
|
||||
}
|
||||
const sourceLightning = liveBoss?.attacks.find((attack) => attack.spellId === 9532
|
||||
|| attack.id === 'spell-9532') ?? null;
|
||||
if (!sourceLightning || sourceLightning.castTimeMs <= 0) {
|
||||
throw new Error('The live Lady Anacondra has no source-scheduled interruptible Lightning Bolt (9532) definition.');
|
||||
}
|
||||
if (!result.fight.sourceLightningDefinition) result.fight.sourceLightningDefinition = sourceLightning;
|
||||
|
||||
if (!result.fight.vowSuccess && vowAttempts < 4 && liveCast
|
||||
&& liveCast.attackId === sourceLightning.id && liveCast.spellId === 9532
|
||||
&& liveCast.interruptible && liveCast.completesAt - current.now > 450
|
||||
&& current.player.selectedTargetId === bossId
|
||||
&& current.player.globalCooldownEndsAt <= current.now
|
||||
&& !current.player.activeCast) {
|
||||
vowAttempts += 1;
|
||||
const beforeCast = liveCast;
|
||||
const presentationBefore = current.presentationEvents.at(-1)?.id ?? 0;
|
||||
const eventsBefore = current.events.map((event) => event.id);
|
||||
const attempt = { attempt: vowAttempts, observedAt: current.now,
|
||||
castStartedAt: beforeCast.startedAt, scheduledCompletionAt: beforeCast.completesAt,
|
||||
remainingMs: beforeCast.completesAt - current.now, activeCastBefore: beforeCast,
|
||||
sourceAttack: sourceLightning, input: 'Shift+Digit5', selectedTargetId: current.player.selectedTargetId,
|
||||
lightningBoltCountBefore: liveBoss.abilityCastCounts?.[sourceLightning.id] ?? null,
|
||||
natureLockedUntilBefore: liveBoss.schoolLockedUntil?.nature ?? 0,
|
||||
playerOriginBefore: current.player.position ?? null,
|
||||
playerCameraYawBefore: current.player.cameraYaw ?? null,
|
||||
bossPositionBefore: liveBoss.position ?? null,
|
||||
bossTransformBefore: liveBoss.transform ?? null,
|
||||
vowTargetingMetadata: { target: vowSetup.target, range: vowSetup.range,
|
||||
radius: vowSetup.radius, targeting: vowSetup.targeting } };
|
||||
result.fight.vowAttempts.push(attempt);
|
||||
await page.screenshot({ path: path.join(outputDirectory, `lightning-bolt-pre-vow-${vowAttempts}.png`) });
|
||||
await page.keyboard.press('Shift+Digit5');
|
||||
attempt.inputAt = Date.now();
|
||||
await page.waitForTimeout(65);
|
||||
const afterVow = await readLiveState(page);
|
||||
const afterBoss = afterVow.mobs.find((mob) => mob.id === bossId);
|
||||
const cancelEvent = afterVow.presentationEvents.find((event) => event.id > presentationBefore
|
||||
&& event.phase === 'cast-cancel' && event.actorGroup === 'enemy'
|
||||
&& event.sourceActorId === bossId && event.abilityId === beforeCast.attackId
|
||||
&& (event.sourceSpellId === 9532 || event.name === sourceLightning.name));
|
||||
const vowEvents = afterVow.events.filter((event) => !eventsBefore.includes(event.id)
|
||||
&& event.abilityId === vowSetup.id && event.sourceActorId === '__player__');
|
||||
const schoolLock = afterBoss?.schoolLockedUntil?.nature ?? 0;
|
||||
const sameObservedCastStillActive = afterBoss?.activeCast?.attackId === beforeCast.attackId
|
||||
&& afterBoss.activeCast.startedAt === beforeCast.startedAt;
|
||||
const sourceCastEndedOnTime = Boolean(!sameObservedCastStillActive && afterVow.now < beforeCast.completesAt);
|
||||
const appliedSilence = afterBoss?.statuses.find((status) => status.kind === 'silence'
|
||||
&& status.sourceAbilityId === 'aura:rom-priest-vow-of-silence'
|
||||
&& status.endsAt > afterVow.now) ?? null;
|
||||
const silenceAura = afterBoss?.auras.find((aura) => aura.id === 'rom-priest-vow-of-silence'
|
||||
&& aura.endsAt > afterVow.now) ?? null;
|
||||
const silenceEndsAt = appliedSilence?.endsAt ?? silenceAura?.endsAt ?? schoolLock;
|
||||
const silenced = silenceEndsAt >= afterVow.now + 7_900;
|
||||
attempt.afterReadAt = afterVow.now;
|
||||
attempt.presentationSubscriptionFound = afterVow.presentationSubscriptionFound;
|
||||
attempt.presentationSubscribeExportName = afterVow.presentationSubscribeExportName;
|
||||
attempt.presentationExportNames = afterVow.presentationExportNames;
|
||||
attempt.presentationSubscriptionCandidates = afterVow.presentationSubscriptionCandidates;
|
||||
attempt.activeCastAfter = afterBoss?.activeCast ?? null;
|
||||
attempt.sourceCastEndedBeforeScheduledCompletion = sourceCastEndedOnTime;
|
||||
attempt.sameObservedCastStillActive = sameObservedCastStillActive;
|
||||
attempt.castIdentityChangedBeforeScheduledCompletion = Boolean(afterBoss?.activeCast
|
||||
&& (afterBoss.activeCast.attackId !== beforeCast.attackId
|
||||
|| afterBoss.activeCast.startedAt !== beforeCast.startedAt)
|
||||
&& sourceCastEndedOnTime);
|
||||
attempt.targetSourceIdentityAfter = afterBoss ? { serverEntry: afterBoss.serverEntry,
|
||||
runewakerNpcId: afterBoss.runewakerNpcId, creatureFamily: afterBoss.creatureFamily } : null;
|
||||
attempt.targetPositionAfter = afterBoss?.position ?? null;
|
||||
attempt.targetTransformAfter = afterBoss?.transform ?? null;
|
||||
attempt.playerOriginBefore = current.player.position ?? null;
|
||||
attempt.playerCameraYawBefore = current.player.cameraYaw ?? null;
|
||||
attempt.targetMechanicImmunitiesAfter = afterBoss?.mechanicImmunities ?? [];
|
||||
attempt.targetSchoolImmunitiesAfter = afterBoss?.schoolImmunities ?? [];
|
||||
attempt.targetStatusesAfter = afterBoss?.statuses ?? [];
|
||||
attempt.targetAurasAfter = afterBoss?.auras ?? [];
|
||||
attempt.castCancelPresentationEvent = cancelEvent ?? null;
|
||||
attempt.playerVowCombatEvents = vowEvents;
|
||||
attempt.natureLockedUntil = schoolLock;
|
||||
attempt.silenceEndsAt = silenceEndsAt;
|
||||
attempt.silenceDurationMsObserved = silenceEndsAt > 0 ? silenceEndsAt - afterVow.now : 0;
|
||||
attempt.silenceEvidence = { appliedMobStatus: appliedSilence, appliedMobAura: silenceAura,
|
||||
schoolLockUntil: schoolLock };
|
||||
attempt.silenceConfirmed = silenced;
|
||||
attempt.vowPresentationEvents = afterVow.presentationEvents.filter((event) => event.id > presentationBefore
|
||||
&& event.actorGroup === 'player' && event.sourceActorId === '__player__'
|
||||
&& event.abilityId === vowSetup.id);
|
||||
attempt.presentationEventsAfterInput = afterVow.presentationEvents.filter((event) => event.id > presentationBefore);
|
||||
attempt.vowEffectEvents = afterVow.events.filter((event) => event.abilityId === vowSetup.id
|
||||
&& event.sourceActorId === '__player__' && event.occurredAt >= attempt.inputAt);
|
||||
attempt.confirmedEffectTargets = [{ actorId: afterBoss?.id ?? null,
|
||||
status: appliedSilence, aura: silenceAura }].filter((effect) => effect.status || effect.aura);
|
||||
await page.screenshot({ path: path.join(outputDirectory, `lightning-bolt-post-vow-${vowAttempts}.png`) });
|
||||
current = afterVow;
|
||||
if (sourceCastEndedOnTime && silenced && vowEvents.some((event) => event.targetActorId === bossId)) {
|
||||
result.fight.vowSuccess = true;
|
||||
result.fight.vowSuccessAt = Math.min(...vowEvents.map((event) => event.occurredAt));
|
||||
result.fight.silenceEndsAt = silenceEndsAt;
|
||||
result.fight.cancellationEvidence = { liveSourceCastBefore: beforeCast,
|
||||
activeCastAfter: afterBoss?.activeCast ?? null, sourceCastEndedOnTime,
|
||||
cancelPresentationEvent: cancelEvent ?? null,
|
||||
statusAppliedToBoss: appliedSilence, silenceEndsAt };
|
||||
result.fight.silenceWindow = { startedAt: result.fight.vowSuccessAt, expectedEndsAt: silenceEndsAt,
|
||||
lightningBoltAttackId: sourceLightning.id,
|
||||
lightningBoltCountAtStart: afterBoss.abilityCastCounts?.[sourceLightning.id] ?? null,
|
||||
enemyLightningBoltStarts: [],
|
||||
statusSamples: [{ at: afterVow.now, active: Boolean(appliedSilence && appliedSilence.endsAt > afterVow.now),
|
||||
endsAt: appliedSilence?.endsAt ?? silenceEndsAt, activeCast: afterBoss?.activeCast ?? null }],
|
||||
nonPhysicalCastSamplesDuringActiveStatus: appliedSilence && afterBoss?.activeCast
|
||||
&& afterBoss.activeCast.school !== 'physical'
|
||||
? [{ at: afterVow.now, statusEndsAt: appliedSilence.endsAt, activeCast: afterBoss.activeCast }]
|
||||
: [],
|
||||
sourceNatureCastResumptionsAfterExpiry: [] };
|
||||
observedCastCountAtWindowStart = afterBoss.abilityCastCounts?.[sourceLightning.id] ?? null;
|
||||
await page.keyboard.press('F2');
|
||||
result.fight.partyDefendInput = { at: Date.now(), key: 'F2', command: 'Defend',
|
||||
reason: 'hold the actual encounter while observing Vow silence duration' };
|
||||
}
|
||||
}
|
||||
|
||||
if (result.fight.vowSuccess && current.now < result.fight.silenceEndsAt) {
|
||||
const silenceSampleBoss = current.mobs.find((mob) => mob.id === bossId);
|
||||
const silenceSampleCast = silenceSampleBoss?.activeCast;
|
||||
const activeSilenceStatus = silenceSampleBoss?.statuses.find((status) => status.kind === 'silence'
|
||||
&& status.sourceAbilityId === 'aura:rom-priest-vow-of-silence' && status.endsAt > current.now) ?? null;
|
||||
result.fight.silenceWindow.statusSamples.push({ at: current.now,
|
||||
active: Boolean(activeSilenceStatus), endsAt: activeSilenceStatus?.endsAt ?? null,
|
||||
activeCast: silenceSampleCast ?? null });
|
||||
if (activeSilenceStatus && silenceSampleCast && silenceSampleCast.school !== 'physical') {
|
||||
result.fight.silenceWindow.nonPhysicalCastSamplesDuringActiveStatus.push({ at: current.now,
|
||||
statusEndsAt: activeSilenceStatus.endsAt, activeCast: silenceSampleCast });
|
||||
}
|
||||
const startsDuringSilence = current.presentationEvents.filter((event) => event.actorGroup === 'enemy'
|
||||
&& event.sourceActorId === bossId && event.abilityId === result.fight.silenceWindow.lightningBoltAttackId
|
||||
&& event.phase === 'cast-start' && event.occurredAt >= result.fight.vowSuccessAt
|
||||
&& event.occurredAt < result.fight.silenceEndsAt);
|
||||
result.fight.silenceWindow.enemyLightningBoltStarts = startsDuringSilence;
|
||||
const sampleCount = silenceSampleBoss?.abilityCastCounts?.[result.fight.silenceWindow.lightningBoltAttackId] ?? null;
|
||||
result.fight.silenceWindow.lightningBoltCountAtLastSample = sampleCount;
|
||||
if (sampleCount !== observedCastCountAtWindowStart || silenceSampleCast?.attackId === result.fight.silenceWindow.lightningBoltAttackId) {
|
||||
result.fight.silenceWindow.suppressionFailedAt = current.now;
|
||||
result.fight.outcome = 'lightning-bolt-started-during-silence';
|
||||
break;
|
||||
}
|
||||
const injured = current.party.filter((member) => member.health > 0
|
||||
&& member.health < member.maxHealth * 0.72 && member.position)
|
||||
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0] ?? null;
|
||||
const selfInjured = current.player.health < current.player.maxHealth * 0.72;
|
||||
if ((injured || selfInjured) && result.actionPlan.healing && Date.now() - nextHealAt > 1_100) {
|
||||
const self = selfInjured && (!injured || current.player.health / current.player.maxHealth
|
||||
< injured.health / injured.maxHealth);
|
||||
if (self) await selectPlayerFrame(page, result.profile.name);
|
||||
else await selectPartyMember(page, injured);
|
||||
const cast = await performCast(page, result.actionPlan.healing, 'healing', self ? '__player__' : injured.id,
|
||||
self ? 'Boss Testing Vow silence window survival' : `Boss Testing triage for ${injured.name}`);
|
||||
result.fight.healingActions.push(cast);
|
||||
nextHealAt = Date.now();
|
||||
}
|
||||
await page.waitForTimeout(100);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.fight.vowSuccess && !result.fight.silenceWindow.verifiedAt
|
||||
&& current.now >= result.fight.silenceEndsAt) {
|
||||
const finalBoss = current.mobs.find((mob) => mob.id === bossId);
|
||||
const durationStarts = current.presentationEvents.filter((event) => event.actorGroup === 'enemy'
|
||||
&& event.sourceActorId === bossId && event.abilityId === result.fight.silenceWindow.lightningBoltAttackId
|
||||
&& event.phase === 'cast-start' && event.occurredAt >= result.fight.vowSuccessAt
|
||||
&& event.occurredAt < result.fight.silenceEndsAt);
|
||||
result.fight.silenceWindow.enemyLightningBoltStarts = durationStarts;
|
||||
result.fight.silenceWindow.lightningBoltCountAtEnd = finalBoss?.abilityCastCounts?.[result.fight.silenceWindow.lightningBoltAttackId] ?? null;
|
||||
result.fight.silenceWindow.verifiedAt = current.now;
|
||||
result.fight.silenceWindow.durationMsObserved = Math.min(current.now, result.fight.silenceEndsAt)
|
||||
- result.fight.vowSuccessAt;
|
||||
result.fight.silenceWindow.verificationLagMs = current.now - result.fight.silenceEndsAt;
|
||||
result.fight.silenceWindow.nonPhysicalCastStartsDuringSilence = result.fight.castTimeline.filter(({ activeCast }) => (
|
||||
activeCast && activeCast.school !== 'physical'
|
||||
&& activeCast.startedAt >= result.fight.vowSuccessAt
|
||||
&& activeCast.startedAt < result.fight.silenceEndsAt
|
||||
));
|
||||
result.fight.silenceWindow.fullSilenceSuppressionPassed = result.fight.silenceWindow.statusSamples.some((sample) => sample.active)
|
||||
&& result.fight.silenceWindow.nonPhysicalCastSamplesDuringActiveStatus.length === 0
|
||||
&& result.fight.silenceWindow.nonPhysicalCastStartsDuringSilence.length === 0;
|
||||
result.fight.silenceWindow.suppressedNextCast = durationStarts.length === 0
|
||||
&& result.fight.silenceWindow.lightningBoltCountAtEnd === observedCastCountAtWindowStart;
|
||||
if (!result.fight.silenceWindow.suppressedNextCast) {
|
||||
result.fight.outcome = 'lightning-bolt-not-suppressed-through-silence';
|
||||
break;
|
||||
}
|
||||
await page.keyboard.press('F1');
|
||||
result.fight.partyAttackResumedAt = Date.now();
|
||||
}
|
||||
|
||||
if (result.fight.silenceWindow?.verifiedAt && current.now >= result.fight.silenceEndsAt) {
|
||||
const expiredWindowBoss = current.mobs.find((mob) => mob.id === bossId);
|
||||
const resumedCast = expiredWindowBoss?.activeCast;
|
||||
const resumedAttack = resumedCast && expiredWindowBoss.attacks.find((attack) => attack.id === resumedCast.attackId);
|
||||
if (resumedCast && resumedCast.startedAt >= result.fight.silenceEndsAt
|
||||
&& (resumedCast.school === 'nature' || resumedAttack?.school === 'nature')
|
||||
&& !result.fight.silenceWindow.sourceNatureCastResumptionsAfterExpiry.some((entry) => (
|
||||
entry.activeCast.attackId === resumedCast.attackId && entry.activeCast.startedAt === resumedCast.startedAt
|
||||
))) {
|
||||
result.fight.silenceWindow.sourceNatureCastResumptionsAfterExpiry.push({ at: current.now,
|
||||
activeCast: resumedCast, sourceAttack: resumedAttack ?? null });
|
||||
}
|
||||
}
|
||||
|
||||
const injured = current.party.filter((member) => member.health > 0
|
||||
&& member.health < member.maxHealth * 0.72 && member.position)
|
||||
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0] ?? null;
|
||||
const selfInjured = current.player.health < current.player.maxHealth * 0.72;
|
||||
if ((injured || selfInjured) && result.actionPlan.healing && Date.now() - nextHealAt > 1_100) {
|
||||
const self = selfInjured && (!injured || current.player.health / current.player.maxHealth
|
||||
< injured.health / injured.maxHealth);
|
||||
if (self) await selectPlayerFrame(page, result.profile.name);
|
||||
else await selectPartyMember(page, injured);
|
||||
const cast = await performCast(page, result.actionPlan.healing, 'healing', self ? '__player__' : injured.id,
|
||||
self ? 'Boss Testing live-fight survival' : `Boss Testing triage for ${injured.name}`);
|
||||
result.fight.healingActions.push(cast);
|
||||
nextHealAt = Date.now();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.fight.vowSuccess && result.fight.silenceWindow.suppressedNextCast
|
||||
&& result.actionPlan.damage && current.player.selectedTargetId === bossId
|
||||
&& !current.player.activeCast && current.player.globalCooldownEndsAt <= current.now
|
||||
&& Date.now() - nextDamageAt > 1_300) {
|
||||
const cast = await performCast(page, result.actionPlan.damage, 'damage', bossId,
|
||||
'normal keyboard damage in Lady Anacondra Boss Testing');
|
||||
result.fight.damageActions.push(cast);
|
||||
nextDamageAt = Date.now();
|
||||
continue;
|
||||
}
|
||||
await page.waitForTimeout(90);
|
||||
}
|
||||
|
||||
if (result.fight.outcome === 'in-progress') result.fight.outcome = '80-second-live-fight-budget-ended';
|
||||
const finalState = await readLiveState(page);
|
||||
const finalBoss = finalState.mobs.find((mob) => mob.id === bossId) ?? null;
|
||||
result.fight.endedAt = Date.now();
|
||||
result.fight.durationMs = result.fight.endedAt - result.fight.startedAt;
|
||||
result.fight.finalVitals = { player: playerVitals(finalState), party: partyVitals(finalState) };
|
||||
result.fight.bossAtFinalRead = finalBoss;
|
||||
result.fight.bossFate = finalBoss?.dead || finalBoss?.health <= 0 ? 'defeated'
|
||||
: finalBoss ? 'alive-at-budget-end' : 'despawned-or-unavailable';
|
||||
result.fight.partySurvivors = finalState.party.filter((member) => member.health > 0).map((member) => member.name);
|
||||
result.fight.partyDeaths = finalState.party.filter((member) => member.health <= 0).map((member) => member.name);
|
||||
result.fight.playerSurvived = finalState.player.health > 0;
|
||||
result.fight.finalPlayerTargetId = finalState.player.selectedTargetId;
|
||||
result.fight.experienceBefore = result.fight.preFight.experience;
|
||||
result.fight.experienceAfter = finalState.player.experience;
|
||||
result.fight.noExperienceAwardObserved = finalState.player.experience === result.fight.preFight.experience;
|
||||
result.fight.noLootObserved = Boolean(finalBoss
|
||||
&& !finalBoss.hasLoot && finalBoss.pendingLootCount === 0);
|
||||
result.fight.finalEvents = finalState.events;
|
||||
await page.screenshot({ path: path.join(outputDirectory, 'fight-end.png') });
|
||||
return finalState;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
let runtime = null;
|
||||
let browser = null;
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
const assetFiles = fs.readdirSync('dist/assets')
|
||||
.filter((file) => /^(?:index|GameRuntime|combatStore|partyStore|PartyFrames|romAbilityCatalog)-.*\.js$/.test(file)).sort();
|
||||
const cssAssetFiles = fs.readdirSync('dist/assets')
|
||||
.filter((file) => /^index-.*\.css$/.test(file)).sort();
|
||||
const assetSnapshotAtStart = [...assetFiles, ...cssAssetFiles].map((file) => {
|
||||
const absolute = path.resolve('dist/assets', file);
|
||||
const bytes = fs.readFileSync(absolute);
|
||||
return { file, bytes: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex') };
|
||||
});
|
||||
const moduleUrls = assetFiles.map((file) => `/assets/${file}`);
|
||||
const result = {
|
||||
status: 'running',
|
||||
buildCapturedAtScriptStart: new Date().toISOString(),
|
||||
snapshotKind: 'final production dist; dedicated Boss Testing fight only',
|
||||
outputDirectory,
|
||||
assetSnapshotAtStart,
|
||||
liveFightBudgetMs,
|
||||
scope: { mode: 'Boss Testing', boss: 'Lady Anacondra', mechanic: 'Vow of Silence (DBC 490275) interrupts source Lightning Bolt (9532)',
|
||||
combatStoreWritesDuringLiveFight: false, stagedEnemyCast: false, allowedSetup: ['UI-created RoM Priest', 'level 30', 'RoM secondary class Knight',
|
||||
'Vow bound through Ability controls UI to Shift+Digit5'], lootExpected: false, readinessChanges: false },
|
||||
profile: null,
|
||||
partyAtEntry: [],
|
||||
vowSetup: null,
|
||||
actionPlan: null,
|
||||
fight: { outcome: 'in-progress', vowAttempts: [], vowSuccess: false,
|
||||
damageActions: [], healingActions: [], samples: [], castTimeline: [], presentationEvents: [], pageErrors: [] },
|
||||
pageErrors,
|
||||
consoleErrors,
|
||||
};
|
||||
try {
|
||||
if (assetFiles.length < 3) throw new Error(`Expected final production assets are missing: ${assetFiles.join(', ')}`);
|
||||
const { createGameServer } = await import('../server/server.mjs');
|
||||
runtime = createGameServer({ dataDir: dataDirectory, enableShelvedModes: false });
|
||||
await new Promise((resolve, reject) => {
|
||||
runtime.server.once('error', reject);
|
||||
runtime.server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = runtime.server.address();
|
||||
browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
page.setDefaultTimeout(120_000);
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() });
|
||||
});
|
||||
await page.addInitScript(({ urls }) => {
|
||||
window.vowFinalPresentationEvents = [];
|
||||
window.vowFinalStores = async () => {
|
||||
const modules = await Promise.all(urls.map((url) => import(url)));
|
||||
const namedExports = modules.flatMap((module) => Object.entries(module));
|
||||
const exports = namedExports.map(([, value]) => value);
|
||||
const stores = exports.filter((value) => value && typeof value.getState === 'function');
|
||||
const subscribe = namedExports.find(([name, value]) => name === 'subscribeCombatPresentation'
|
||||
&& typeof value === 'function');
|
||||
window.vowFinalPresentationExportNames = namedExports.map(([name]) => name)
|
||||
.filter((name) => name.toLowerCase().includes('presentation'));
|
||||
if (subscribe && !window.vowFinalUnsubscribe) {
|
||||
window.vowFinalPresentationSubscriptionFound = true;
|
||||
window.vowFinalPresentationSubscribeExportName = subscribe[0];
|
||||
window.vowFinalUnsubscribe = subscribe[1]((message) => {
|
||||
if (message?.kind === 'event') window.vowFinalPresentationEvents.push(message.event);
|
||||
if (window.vowFinalPresentationEvents.length > 500) window.vowFinalPresentationEvents.shift();
|
||||
});
|
||||
}
|
||||
const transformExport = namedExports.find(([, value]) => {
|
||||
if (typeof value !== 'function') return false;
|
||||
const source = Function.prototype.toString.call(value);
|
||||
return source.includes('.mounted') && source.includes('.value') && source.includes('position:')
|
||||
&& source.includes('yaw:') && source.includes('null');
|
||||
});
|
||||
const partyPositionExport = namedExports.find(([, value]) => {
|
||||
if (typeof value !== 'function') return false;
|
||||
const source = Function.prototype.toString.call(value);
|
||||
return /\.get\(/.test(source)
|
||||
&& /return\s+[A-Za-z_$][\w$]*\s*\?\s*\[\s*\.\.\.[A-Za-z_$][\w$]*\s*\]\s*:\s*null/.test(source);
|
||||
});
|
||||
const presentationSubscriptionCandidates = namedExports.filter(([, value]) => {
|
||||
if (typeof value !== 'function') return false;
|
||||
const source = Function.prototype.toString.call(value);
|
||||
return source.includes('.add(') && source.includes('.delete(') && source.includes('=>');
|
||||
}).map(([name, value]) => ({ name, source: Function.prototype.toString.call(value) }));
|
||||
window.vowFinalPresentationSubscriptionCandidates = presentationSubscriptionCandidates;
|
||||
return {
|
||||
getMobPosition: transformExport?.[1] ? (id) => transformExport[1](id)?.position ?? null : null,
|
||||
getMobTransform: transformExport?.[1] ?? null,
|
||||
getPartyPosition: partyPositionExport?.[1] ?? null,
|
||||
game: stores.find((store) => typeof store.getState()?.relocatePlayer === 'function'),
|
||||
shell: stores.find((store) => typeof store.getState()?.saveActiveProgression === 'function'),
|
||||
combat: stores.find((store) => typeof store.getState()?.castAbility === 'function'),
|
||||
party: stores.find((store) => typeof store.getState()?.setCommand === 'function'
|
||||
&& Array.isArray(store.getState()?.members)),
|
||||
};
|
||||
};
|
||||
}, { urls: moduleUrls });
|
||||
await page.goto(`http://127.0.0.1:${address.port}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('button', { name: /Play offline/ }).click();
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
|
||||
await page.locator('.class-option').filter({ hasText: /^Priest/ }).click();
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill('VowLiveFinalQA');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.character-select-screen').waitFor();
|
||||
const profile = await page.evaluate(async () => {
|
||||
const { shell } = await window.vowFinalStores();
|
||||
const state = shell.getState();
|
||||
const character = state.characters.find((entry) => entry.id === state.selectedCharacterId);
|
||||
if (!character || character.classId !== 'rom-priest') throw new Error('UI-created RoM Priest profile is missing.');
|
||||
shell.setState({ activeCharacter: character });
|
||||
shell.getState().saveActiveProgression(30, 0, character.talentRanks);
|
||||
shell.setState({ activeCharacter: null });
|
||||
if (!shell.getState().chooseRomSecondaryClass('rom-knight')) throw new Error('Priest/Knight pairing was not accepted.');
|
||||
const updated = shell.getState().characters.find((entry) => entry.id === character.id);
|
||||
return { id: updated.id, name: updated.name, classId: updated.classId,
|
||||
secondaryClassId: updated.secondaryClassId, level: updated.level, categoryId: updated.categoryId };
|
||||
});
|
||||
assert.equal(profile.classId, 'rom-priest');
|
||||
assert.equal(profile.secondaryClassId, 'rom-knight');
|
||||
assert.equal(profile.level, 30);
|
||||
result.profile = profile;
|
||||
await page.waitForTimeout(1_000);
|
||||
await page.screenshot({ path: path.join(outputDirectory, 'roster-priest-knight-level-30.png') });
|
||||
await page.locator('.roster-enter').click();
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
await page.getByRole('button', { name: /^GM Menu/ }).click();
|
||||
await page.getByRole('button', { name: /Boss Testing/ }).click();
|
||||
await page.getByRole('heading', { name: 'Boss Testing', exact: true }).waitFor();
|
||||
const picker = page.getByLabel(/Choose a boss/);
|
||||
const option = await picker.locator('option').evaluateAll((options) => options
|
||||
.find((entry) => entry.textContent.startsWith('Lady Anacondra ·'))?.value ?? null);
|
||||
assert(option, 'Lady Anacondra appears in the dedicated Boss Testing catalog.');
|
||||
await picker.selectOption(option);
|
||||
const selectedArticle = page.locator('.boss-testing-selected');
|
||||
await selectedArticle.getByRole('heading', { name: 'Lady Anacondra', exact: true }).waitFor();
|
||||
await page.screenshot({ path: path.join(outputDirectory, 'boss-testing-lady-selection.png') });
|
||||
await page.getByRole('button', { name: 'Test selected boss', exact: true }).click();
|
||||
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
|
||||
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 180_000 }).catch(() => {});
|
||||
await page.waitForFunction(async () => {
|
||||
const { game, combat } = await window.vowFinalStores();
|
||||
const current = game.getState();
|
||||
const boss = current.bossTest;
|
||||
return current.assetStatus === 'ready' && boss?.name === 'Lady Anacondra'
|
||||
&& boss.runtimeIds.every((id) => combat.getState().mobs[id]);
|
||||
}, null, { timeout: 240_000 });
|
||||
await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached', timeout: 15_000 }).catch(() => {});
|
||||
await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur());
|
||||
let entry = await readLiveState(page, true);
|
||||
assert.equal(entry.game.bossTest?.name, 'Lady Anacondra');
|
||||
assert.equal(entry.game.gmTestMode, true, 'Selected room is actual Boss Testing, through its UI flow.');
|
||||
assert.equal(entry.player.classId, 'rom-priest');
|
||||
assert.equal(entry.player.secondaryClassId, 'rom-knight');
|
||||
assert.equal(entry.party.length, 4, 'Boss Testing generated four owned NPC companions.');
|
||||
assert(entry.party.every((member) => member.classId.startsWith('rom-') && member.statRuleset === 'rom'),
|
||||
'All four NPCs have native RoM identities.');
|
||||
result.partyAtEntry = partyVitals(entry);
|
||||
result.partyIdentityAtEntry = entry.party.map((member) => ({ id: member.id, name: member.name,
|
||||
classId: member.classId, secondaryClassId: member.secondaryClassId, role: member.role,
|
||||
statRuleset: member.statRuleset }));
|
||||
await page.screenshot({ path: path.join(outputDirectory, 'lady-anacondra-before-vow.png') });
|
||||
result.vowSetup = await assignVowThroughAbilityControls(page, outputDirectory);
|
||||
entry = await readLiveState(page, true);
|
||||
result.actionPlan = actionPlanFromSnapshot(entry, result.vowSetup.id);
|
||||
if (!result.actionPlan.healing) throw new Error('No normal keyboard-bound Priest healing action is available.');
|
||||
const boss = entry.mobs.find((mob) => mob.id === entry.game.bossTest.runtimeIds[0]);
|
||||
if (!boss || !boss.boss || boss.name !== 'Lady Anacondra') throw new Error('Boss Testing did not spawn the expected boss.');
|
||||
result.fight.preFight = { at: entry.now, bossId: boss.id, boss: { id: boss.id, name: boss.name,
|
||||
level: boss.level, health: boss.health, maxHealth: boss.maxHealth,
|
||||
attacks: boss.attacks, abilityCastCounts: boss.abilityCastCounts },
|
||||
player: playerVitals(entry), party: partyVitals(entry), game: entry.game,
|
||||
experience: entry.player.experience };
|
||||
await page.screenshot({ path: path.join(outputDirectory, 'lady-anacondra-fight-start.png') });
|
||||
await runFight(page, result, result.vowSetup, entry);
|
||||
assert.equal(result.fight.vowSuccess, true,
|
||||
'A live scheduled Lightning Bolt ended early and Vow applied its full-duration silence status.');
|
||||
assert.equal(result.fight.silenceWindow.suppressedNextCast, true,
|
||||
'No subsequent Lightning Bolt began during the observed silence window.');
|
||||
assert.equal(result.fight.silenceWindow.fullSilenceSuppressionPassed, true,
|
||||
'No nonphysical Lady Anacondra cast became active during Vow silence.');
|
||||
assert.equal(result.fight.noLootObserved, true, 'Boss Testing did not create boss loot.');
|
||||
assert.equal(result.fight.noExperienceAwardObserved, true, 'Boss Testing did not award XP.');
|
||||
assert.deepEqual(pageErrors, [], `No browser page exceptions: ${JSON.stringify(pageErrors)}`);
|
||||
result.status = 'complete';
|
||||
result.pageErrors = pageErrors;
|
||||
result.consoleErrors = consoleErrors;
|
||||
result.fight.pageErrors = pageErrors;
|
||||
result.completedAt = new Date().toISOString();
|
||||
fs.writeFileSync(path.join(outputDirectory, 'results.json'), `${JSON.stringify(result, null, 2)}\n`);
|
||||
console.log(`Live Vow of Silence QA complete: ${outputDirectory}`);
|
||||
} catch (error) {
|
||||
result.status = 'failed';
|
||||
result.error = error.stack ?? error.message;
|
||||
result.pageErrors = pageErrors;
|
||||
result.consoleErrors = consoleErrors;
|
||||
result.fight.pageErrors = pageErrors;
|
||||
result.completedAt = new Date().toISOString();
|
||||
if (browser) {
|
||||
const contexts = browser.contexts();
|
||||
const page = contexts[0]?.pages()[0];
|
||||
if (page) await page.screenshot({ path: path.join(outputDirectory, 'failure.png') }).catch(() => {});
|
||||
if (page) result.failureBodyText = (await page.locator('body').innerText().catch(() => '')).slice(-3_000);
|
||||
}
|
||||
fs.writeFileSync(path.join(outputDirectory, 'results.json'), `${JSON.stringify(result, null, 2)}\n`);
|
||||
console.error(`Live Vow of Silence QA failed: ${outputDirectory}`);
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (runtime) await new Promise((resolve) => runtime.server.close(resolve));
|
||||
const target = fs.realpathSync(dataDirectory);
|
||||
if (path.dirname(target) !== fs.realpathSync(os.tmpdir())
|
||||
|| !path.basename(target).startsWith('healer-man-vow-live-final-')) {
|
||||
throw new Error('Unsafe temporary playtest database cleanup path.');
|
||||
}
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error.stack ?? error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -483,6 +483,9 @@ function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
|
||||
delivery: spell.runtime.delivery,
|
||||
target: spell.runtime.target,
|
||||
school: spell.runtime.school,
|
||||
damageOrigin: "runewaker",
|
||||
attackKind: spell.runtime.attackKind,
|
||||
runewakerEffectType: spell.runtime.runewakerEffectType,
|
||||
animation: spell.runtime.animation,
|
||||
range: spell.runtime.range,
|
||||
cooldownMs: spell.runtime.cooldownMs,
|
||||
@@ -549,6 +552,7 @@ function entityFor(template, actor, sourceSpells, spellCatalog, bossMechanics) {
|
||||
...(boss && template.title ? { title: template.title } : {}),
|
||||
hasLoot: template.hasLoot === true,
|
||||
combat: {
|
||||
runewakerNpcId: Number(template.id),
|
||||
level: template.level,
|
||||
healthMultiplier: combat.healthMultiplier ?? (boss ? defaults.bossHealthMultiplier ?? 7.5 : defaults.healthMultiplier ?? 1),
|
||||
damageMultiplier: combat.damageMultiplier ?? (boss ? defaults.bossDamageMultiplier ?? 1.3 : defaults.damageMultiplier ?? 1),
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { projectRoot, readJson, sourceRootFor } from "./lib/recipe.mjs";
|
||||
|
||||
const DATA_START = 140;
|
||||
const NPC_SIZE = 2224;
|
||||
const TABLE_SIZE = 408;
|
||||
const NPC_TYPE = 1;
|
||||
const NPC_RACE = 176;
|
||||
const NPC_SPELL_START = 568;
|
||||
const NPC_SPELL_SIZE = 28;
|
||||
const NPC_RESIST_START = 888;
|
||||
const NPC_BOW_EQUIPMENT = 1260;
|
||||
const NPC_ORNAMENT_EQUIPMENT = 1648;
|
||||
const SPELL_COUNT = 8;
|
||||
const FIX_FIELDS = [
|
||||
"damageAttackCoefficient", "magicalDamageAttackCoefficient", "criticalLevelCoefficient", "damageLevelCoefficient",
|
||||
"missLevelCoefficient", "secondHandMissCoefficient", "secondHandDamageCoefficient", "basePhysicalCritical",
|
||||
"basePhysicalCriticalPower", "baseMagicalCritical", "baseMagicalCriticalPower", "magicDamageCoefficient",
|
||||
"magicMagicalDamageCoefficient", "dotMagicalDamageCoefficient", "healingMagicalDamageCoefficient",
|
||||
"healingDotMagicalDamageCoefficient", "baseRateCoefficient", "baseResistCoefficient", "levelRateCoefficient",
|
||||
"levelResistCoefficient", "physicalHitBase", "physicalHitRange", "physicalHitAgility", "physicalDodgeAgility",
|
||||
"magicalHitBase", "magicalHitRange", "magicalHitWisdom", "magicalDodgeAgility", "magicalDodgeWisdom",
|
||||
"phantomRankCoefficient", "phantomLevelCoefficient",
|
||||
];
|
||||
const CLASS_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const NPC_CLASS_IDS = [0, ...CLASS_IDS];
|
||||
const STAT_NAMES = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
|
||||
const GROWTH_NAMES = [
|
||||
"baseStrengthLevelOne", "baseStaminaLevelOne", "baseIntellectLevelOne", "baseWisdomLevelOne",
|
||||
"baseAgilityLevelOne", "baseHealthLevelOne", "baseManaLevelOne", "baseStrengthFlatGrowth",
|
||||
"baseStaminaFlatGrowth", "baseIntellectFlatGrowth", "baseWisdomFlatGrowth", "baseAgilityFlatGrowth",
|
||||
"baseHealthFlatGrowth", "baseManaFlatGrowth", "strengthGrowthRate", "staminaGrowthRate", "intellectGrowthRate",
|
||||
"wisdomGrowthRate", "agilityGrowthRate", "healthGrowthRate", "manaGrowthRate", "hpStrength", "hpStamina",
|
||||
"mpIntellect", "mpWisdom", "attackStrength", "attackIntellect", "attackAgility", "defenseStamina",
|
||||
"baseHealthRecovery", "baseManaRecovery", "healthRecoveryStamina", "manaRecoveryWisdom",
|
||||
"magicalAttackIntellect", "magicalDefenseWisdom",
|
||||
];
|
||||
|
||||
const recipeDirectory = path.join(projectRoot, "scripts/runewaker-pipeline/recipes");
|
||||
const recipes = await Promise.all((await readdir(recipeDirectory))
|
||||
.filter((name) => name.endsWith("-population.json"))
|
||||
.sort()
|
||||
.map((name) => readJson(path.join(recipeDirectory, name))));
|
||||
if (!recipes.length) throw new Error("No population recipes found.");
|
||||
const sourceRoot = sourceRootFor(recipes[0]);
|
||||
const dataRoot = path.join(sourceRoot, recipes[0].source.resourceRoot ?? "Resource", "data");
|
||||
const readSource = (name) => readFile(path.join(dataRoot, name));
|
||||
const [npcDb, collectDb, magicDb, vocDb, raceDb, sexDb, fixDb, skillBaseDb, jobSkillDb] = await Promise.all([
|
||||
readSource("npcobject.db"), readSource("magiccollectobject.db"), readSource("magicobject.db"),
|
||||
readSource("voctable.db"), readSource("racetable.db"), readSource("sextable.db"), readSource("fixtable.db"),
|
||||
readSource("skillbasetable.db"), readSource("jobskill.db"),
|
||||
]);
|
||||
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
|
||||
const int32 = (buffer, offset) => buffer.readInt32LE(offset);
|
||||
const float32 = (buffer, offset) => {
|
||||
const value = buffer.readFloatLE(offset);
|
||||
if (!Number.isFinite(value)) throw new Error(`Non-finite native source float at ${offset}.`);
|
||||
return Number(value.toFixed(6));
|
||||
};
|
||||
function fixedRecords(buffer, size, type, label) {
|
||||
const count = int32(buffer, 132);
|
||||
if (count <= 0 || DATA_START + count * size > buffer.length) throw new Error(`${label} fixed record layout changed.`);
|
||||
if (type !== null) {
|
||||
for (const i of [0, Math.floor(count / 2), count - 1]) {
|
||||
if (int32(buffer, DATA_START + i * size + 4) !== type) throw new Error(`${label} type sentinel drifted.`);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
function indexById(buffer, size, count) {
|
||||
const result = new Map();
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const offset = DATA_START + i * size;
|
||||
const id = int32(buffer, offset);
|
||||
if (id > 0) result.set(id, offset);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const npcCount = fixedRecords(npcDb, NPC_SIZE, NPC_TYPE, "NPCObjectDB");
|
||||
const collectCount = fixedRecords(collectDb, 1032, 21, "MagicCollectObjectDB");
|
||||
const magicCount = fixedRecords(magicDb, 992, 3, "MagicObjectDB");
|
||||
const npcOffsets = indexById(npcDb, NPC_SIZE, npcCount);
|
||||
const collectOffsets = indexById(collectDb, 1032, collectCount);
|
||||
const magicOffsets = indexById(magicDb, 992, magicCount);
|
||||
|
||||
function npcRow(id) {
|
||||
const offset = npcOffsets.get(id);
|
||||
if (offset === undefined || int32(npcDb, offset + 4) !== NPC_TYPE) throw new Error(`Missing NPCObjectDB row ${id}.`);
|
||||
const int = (delta) => int32(npcDb, offset + delta);
|
||||
const spells = [];
|
||||
for (let slot = 0; slot < SPELL_COUNT; slot += 1) {
|
||||
const start = offset + NPC_SPELL_START + slot * NPC_SPELL_SIZE;
|
||||
const sourceSkillId = int32(npcDb, start + 12);
|
||||
const magicLevel = int32(npcDb, start + 16);
|
||||
if (sourceSkillId <= 0 || !collectOffsets.has(sourceSkillId)) continue;
|
||||
spells.push({ slot, rightTime: int32(npcDb, start), targetType: int32(npcDb, start + 4), rate: int32(npcDb, start + 8), sourceSkillId, magicLevel });
|
||||
}
|
||||
return {
|
||||
npcId: id,
|
||||
raceId: int(NPC_RACE),
|
||||
vocationId: int(NPC_RACE + 4),
|
||||
vocationSubId: int(NPC_RACE + 8),
|
||||
sexId: int(NPC_RACE + 12),
|
||||
level: int(NPC_RACE + 24),
|
||||
levelRange: int(NPC_RACE + 28),
|
||||
authoredAttributes: {
|
||||
strength: int(NPC_RACE + 32), stamina: int(NPC_RACE + 36), intellect: int(NPC_RACE + 40),
|
||||
wisdom: int(NPC_RACE + 44), agility: int(NPC_RACE + 48),
|
||||
},
|
||||
nativeMagicResistances: Array.from({ length: 6 }, (_, type) => float32(npcDb, offset + NPC_RESIST_START + type * 4)),
|
||||
// RD_Setting chooses rand()%5: keep zero rows and duplicates so later
|
||||
// equipment resolution can preserve the source candidate distribution.
|
||||
mainHandEquipmentIds: Array.from({ length: 5 }, (_, i) => int(412 + i * 4)),
|
||||
offHandEquipmentIds: Array.from({ length: 5 }, (_, i) => int(432 + i * 4)),
|
||||
bowEquipmentId: int(NPC_BOW_EQUIPMENT),
|
||||
ornamentEquipmentId: int(NPC_ORNAMENT_EQUIPMENT),
|
||||
spellBindings: spells.map((spell) => ({ ...spell, ...collectBinding(spell.sourceSkillId) })),
|
||||
};
|
||||
}
|
||||
|
||||
function componentDescriptor(componentId) {
|
||||
const offset = magicOffsets.get(componentId);
|
||||
if (offset === undefined) return null;
|
||||
const settingFlags = int32(magicDb, offset + 0xd8);
|
||||
const settingExFlags = int32(magicDb, offset + 988);
|
||||
return {
|
||||
componentId,
|
||||
magicFunc: int32(magicDb, offset + 0xb0),
|
||||
magicType: int32(magicDb, offset + 0xb4),
|
||||
effectType: int32(magicDb, offset + 0xb8),
|
||||
effectTimeSeconds: float32(magicDb, offset + 0xbc),
|
||||
levelDeltaEffectTime: float32(magicDb, offset + 0xc0),
|
||||
effectTimeSkillLevelArg: float32(magicDb, offset + 0xc4),
|
||||
maxBuffLevelBase: int32(magicDb, offset + 0xcc),
|
||||
maxBuffLevelSkillArg: float32(magicDb, offset + 0xd0),
|
||||
settingFlags,
|
||||
isDotMagic: (settingFlags & (1 << 3)) !== 0,
|
||||
isShowPowerLv: (settingFlags & (1 << 14)) !== 0,
|
||||
isNoKill: (settingFlags & (1 << 15)) !== 0,
|
||||
isTargetBound: (settingFlags & (1 << 17)) !== 0,
|
||||
isFixDotPower: (settingFlags & (1 << 20)) !== 0,
|
||||
dotTimeSeconds: int32(magicDb, offset + 0x184),
|
||||
dotType: int32(magicDb, offset + 0x188),
|
||||
dotBase: int32(magicDb, offset + 0x18c),
|
||||
dotSkillLevelArg: float32(magicDb, offset + 0x190),
|
||||
attackType: int32(magicDb, offset + 0x194),
|
||||
damagePower: float32(magicDb, offset + 0x198),
|
||||
damagePowerSkillLevelArg: float32(magicDb, offset + 0x19c),
|
||||
fixedValue: float32(magicDb, offset + 0x1a0),
|
||||
fixedType: int32(magicDb, offset + 0x1a4),
|
||||
fixedDamageSkillLevelArg: float32(magicDb, offset + 0x1a8),
|
||||
randomRange: float32(magicDb, offset + 0x1ac),
|
||||
criticalRate: int32(magicDb, offset + 0x1b0),
|
||||
calculationType: int32(magicDb, offset + 0x2d4),
|
||||
buffMaxLevel: int32(magicDb, offset + 0x3b4),
|
||||
ignoreMagicAbsorb: (settingExFlags & 1) !== 0,
|
||||
rawSettingExFlags: settingExFlags,
|
||||
isFixDmgEffect: false,
|
||||
};
|
||||
}
|
||||
|
||||
function collectBinding(sourceSkillId) {
|
||||
const offset = collectOffsets.get(sourceSkillId);
|
||||
if (offset === undefined) return { missingSource: true, components: [] };
|
||||
const componentIds = new Set();
|
||||
for (let field = 0x180; field <= 0x2c0; field += 4) {
|
||||
const id = int32(collectDb, offset + field);
|
||||
if (magicOffsets.has(id)) componentIds.add(id);
|
||||
}
|
||||
const rawCollectMode = int32(collectDb, offset + 0x368);
|
||||
return {
|
||||
magicCollect: {
|
||||
effectType: int32(collectDb, offset + 0xb4),
|
||||
rangeType: int32(collectDb, offset + 0xc4),
|
||||
decreaseArg: int32(collectDb, offset + 0xcc),
|
||||
hitRateFunc: int32(collectDb, offset + 0xd4),
|
||||
hitRateArgs: [float32(collectDb, offset + 0xd8), float32(collectDb, offset + 0xdc)],
|
||||
spellTimeSeconds: float32(collectDb, offset + 0x114),
|
||||
rawSettingExFlags: int32(collectDb, offset + 0x110),
|
||||
rawCollectMode,
|
||||
isFixDmgEffect: (rawCollectMode & (1 << 16)) !== 0,
|
||||
isShareDamage: (rawCollectMode & (1 << 18)) !== 0,
|
||||
},
|
||||
components: [...componentIds].sort((a, b) => a - b).map(componentDescriptor).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
const entityIdToNpcId = {};
|
||||
const npcIds = new Set();
|
||||
for (const recipe of recipes) {
|
||||
const generatedPath = path.join(projectRoot, recipe.files.generatedSource);
|
||||
const generated = await readFile(generatedPath, "utf8");
|
||||
const runtimePrefix = recipe.runtimeIdPrefix ?? `rw-${recipe.dungeonId}`;
|
||||
const entityPattern = new RegExp(`"(${runtimePrefix}-([0-9]+))"\\s*:`, "g");
|
||||
for (const match of generated.matchAll(entityPattern)) {
|
||||
const entityId = match[1];
|
||||
const npcId = Number(match[2]);
|
||||
if (!Number.isSafeInteger(npcId) || npcId <= 0) throw new Error(`Invalid Runewaker NPC id in ${entityId}.`);
|
||||
if (entityIdToNpcId[entityId] !== undefined && entityIdToNpcId[entityId] !== npcId) throw new Error(`Entity mapping collision for ${entityId}.`);
|
||||
entityIdToNpcId[entityId] = npcId;
|
||||
npcIds.add(npcId);
|
||||
}
|
||||
}
|
||||
if (!npcIds.size) throw new Error("No generated population entity to NPC mappings were found.");
|
||||
const npcs = [...npcIds].sort((a, b) => a - b).map(npcRow);
|
||||
|
||||
// Combat hit bindings are exported only for source rows already used by the
|
||||
// native damage/periodic helpers. This preserves the bound skill/component
|
||||
// pair without attempting to infer player hit rules from an ability label.
|
||||
const playerDamageCatalog = await readJson(path.join(projectRoot, "src/game/generated/romNativeHolyHealBindings.generated.json"));
|
||||
const playerPeriodicCatalog = await readJson(path.join(projectRoot, "src/game/generated/romNativePeriodicDamageBindings.generated.json"));
|
||||
const playerSkillIds = new Set();
|
||||
for (const ability of [...(playerDamageCatalog.attackBindings ?? []), ...(playerDamageCatalog.holyHealBindings ?? [])]) {
|
||||
for (const rank of ability.ranks ?? []) if (Number.isSafeInteger(rank.sourceSkillId)) playerSkillIds.add(rank.sourceSkillId);
|
||||
}
|
||||
for (const ability of playerPeriodicCatalog.activeDamageBindings ?? []) {
|
||||
for (const rank of ability.ranks ?? []) if (Number.isSafeInteger(rank.sourceSkillId)) playerSkillIds.add(rank.sourceSkillId);
|
||||
}
|
||||
const playerSpellBindings = [...playerSkillIds].sort((a, b) => a - b).map((sourceSkillId) => {
|
||||
const binding = collectBinding(sourceSkillId);
|
||||
return binding.missingSource ? null : { sourceSkillId, ...binding };
|
||||
}).filter(Boolean);
|
||||
|
||||
const growthTable = (buffer, rowSize, count, expectedBase) => {
|
||||
const rows = new Map();
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const offset = DATA_START + i * rowSize;
|
||||
const id = int32(buffer, offset);
|
||||
if (id < expectedBase) continue;
|
||||
rows.set(id, Array.from({ length: 100 }, (_, index) => float32(buffer, offset + 8 + index * 4)));
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
fixedRecords(vocDb, TABLE_SIZE, null, "VocationTable");
|
||||
fixedRecords(raceDb, TABLE_SIZE, null, "RaceTable");
|
||||
fixedRecords(sexDb, TABLE_SIZE, null, "SexTable");
|
||||
fixedRecords(fixDb, TABLE_SIZE, null, "FixTable");
|
||||
const vocCount = (vocDb.length - DATA_START) / TABLE_SIZE;
|
||||
const raceCount = (raceDb.length - DATA_START) / TABLE_SIZE;
|
||||
const sexCount = (sexDb.length - DATA_START) / TABLE_SIZE;
|
||||
const fixCount = (fixDb.length - DATA_START) / TABLE_SIZE;
|
||||
const vocRows = growthTable(vocDb, TABLE_SIZE, vocCount, 301000);
|
||||
const raceRows = growthTable(raceDb, TABLE_SIZE, raceCount, 300000);
|
||||
const sexRows = growthTable(sexDb, TABLE_SIZE, sexCount, 302000);
|
||||
const fixRows = growthTable(fixDb, TABLE_SIZE, fixCount, 303000);
|
||||
const growthFields = (values) => ({
|
||||
growth: Object.fromEntries(STAT_NAMES.map((name, i) => [name, {
|
||||
levelOne: values[i], flat: values[i + 7], percent: values[i + 14],
|
||||
}])),
|
||||
coefficients: Object.fromEntries(GROWTH_NAMES.slice(21).map((name, i) => [name, values[21 + i]])),
|
||||
damageRate: values[43],
|
||||
});
|
||||
const npcClasses = Object.fromEntries(NPC_CLASS_IDS.map((id) => {
|
||||
const values = vocRows.get(301000 + id * 2);
|
||||
if (!values) throw new Error(`Missing NPC vocation row ${301000 + id * 2}.`);
|
||||
return [id, { sourceRecordId: 301000 + id * 2, ...growthFields(values) }];
|
||||
}));
|
||||
const raceGrowth = Object.fromEntries([...raceRows].filter(([id]) => id >= 300000 && id <= 300031).map(([id, values]) => [id, growthFields(values)]));
|
||||
const sexGrowth = Object.fromEntries([...sexRows].filter(([id]) => id >= 302000 && id <= 302004).map(([id, values]) => [id, growthFields(values)]));
|
||||
const fix = (recordId) => {
|
||||
const values = fixRows.get(recordId);
|
||||
if (!values) throw new Error(`Missing FIX row ${recordId}.`);
|
||||
return Object.fromEntries(FIX_FIELDS.map((name, i) => [name, values[i]]));
|
||||
};
|
||||
|
||||
const skillBaseCount = int32(skillBaseDb, 132);
|
||||
const jobSkillCount = int32(jobSkillDb, 132);
|
||||
if (skillBaseCount !== 100 || jobSkillCount < 15) throw new Error("Skill table count drifted.");
|
||||
const skillBaseRows = Array.from({ length: skillBaseCount }, (_, i) => {
|
||||
const offset = DATA_START + i * 88;
|
||||
if (int32(skillBaseDb, offset) !== 305000 + i) throw new Error(`SkillValueBase row ${i} drifted.`);
|
||||
return Array.from({ length: 20 }, (_, j) => int32(skillBaseDb, offset + 8 + j * 4));
|
||||
});
|
||||
const jobSkillRows = Array.from({ length: jobSkillCount }, (_, i) => {
|
||||
const offset = DATA_START + i * 136;
|
||||
if (int32(jobSkillDb, offset) !== 306000 + i) throw new Error(`JobSkillValue row ${i} drifted.`);
|
||||
return Array.from({ length: 32 }, (_, j) => int32(jobSkillDb, offset + 8 + j * 4));
|
||||
});
|
||||
const maxSkillByClass = Object.fromEntries(NPC_CLASS_IDS.map((classId) => [classId,
|
||||
Array.from({ length: skillBaseCount }, (_, levelIndex) => Array.from({ length: 15 }, (_, skillId) => {
|
||||
const skillType = jobSkillRows[skillId][classId];
|
||||
if (!Number.isInteger(skillType) || skillType < 0 || skillType >= 20) throw new Error(`Invalid skill type ${skillType}.`);
|
||||
return skillBaseRows[levelIndex][skillType];
|
||||
})),
|
||||
]));
|
||||
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
source: {
|
||||
npcObjectSha256: sha256(npcDb), magicCollectSha256: sha256(collectDb), magicObjectSha256: sha256(magicDb),
|
||||
vocationTableSha256: sha256(vocDb), raceTableSha256: sha256(raceDb), sexTableSha256: sha256(sexDb),
|
||||
fixTableSha256: sha256(fixDb), skillBaseTableSha256: sha256(skillBaseDb), jobSkillTableSha256: sha256(jobSkillDb),
|
||||
npcRecordSize: NPC_SIZE, npcRaceOffset: NPC_RACE, npcSpellStartOffset: NPC_SPELL_START,
|
||||
npcSpellStride: NPC_SPELL_SIZE, npcEquipmentOffsets: { mainHand: 412, offHand: 432 },
|
||||
npcMagicResistanceOffset: NPC_RESIST_START,
|
||||
npcFixedEquipmentOffsets: { bow: NPC_BOW_EQUIPMENT, ornament: NPC_ORNAMENT_EQUIPMENT },
|
||||
npcClassFixTable: 0, playerFixTable: 1, playerSkillPolicy: "simulator-max-trained-by-source-tables",
|
||||
playerBindingCatalogs: ["romNativeHolyHealBindings.generated.json", "romNativePeriodicDamageBindings.generated.json"],
|
||||
},
|
||||
sourceRules: {
|
||||
npcClasses, raceGrowth, sexGrowth, npcFix: fix(303000), playerFix: fix(303001), maxSkillByClass,
|
||||
npcLevelClamp: 200, growthFormulaMaxSourceLevel: 200, maxSkillTableLevel: skillBaseCount,
|
||||
},
|
||||
entityIdToNpcId,
|
||||
npcs,
|
||||
playerSpellBindings,
|
||||
};
|
||||
await writeFile(path.join(projectRoot, "src/game/generated/romNativeCombatData.generated.json"), `${JSON.stringify(output)}\n`);
|
||||
console.log(`Exported ${npcs.length} NPC rows and ${Object.keys(entityIdToNpcId).length} exact population entity→NPC mappings.`);
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDirectory, "../..");
|
||||
const inputPath = path.join(projectRoot, "src", "game", "generated", "romHeirloomReference.json");
|
||||
const outputPath = path.join(projectRoot, "src", "game", "generated", "romNativeDamageTrainingProfiles.generated.json");
|
||||
const classes = ["rom-knight", "rom-priest", "rom-scout", "rom-rogue", "rom-mage"];
|
||||
const slots = ["main-hand", "off-hand", "ranged"];
|
||||
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
|
||||
|
||||
const inputBuffer = await readFile(inputPath);
|
||||
const catalog = JSON.parse(inputBuffer.toString("utf8"));
|
||||
const selectedItemIds = new Set(Object.values(catalog.profiles).flatMap((profile) =>
|
||||
Object.values(profile).flatMap((curve) => curve.flatMap((row) => row.referenceItemIds))));
|
||||
const selectedItems = catalog.sourceItems.filter((item) => selectedItemIds.has(item.itemId));
|
||||
const itemById = new Map(selectedItems.map((item) => [item.itemId, item]));
|
||||
if (selectedItems.length !== selectedItemIds.size || selectedItems.some((item) =>
|
||||
item.unmappedEffects?.length || item.unresolvedAttributeIds?.length)) {
|
||||
throw new Error("Training reference has unresolved native body channels.");
|
||||
}
|
||||
const profiles = {};
|
||||
for (const classId of classes) {
|
||||
const sourceProfile = catalog.profiles?.[classId];
|
||||
if (!sourceProfile) throw new Error(`Missing source training profile for ${classId}.`);
|
||||
const classProfile = {};
|
||||
for (const slot of slots) {
|
||||
const curve = sourceProfile[slot];
|
||||
if (!curve) continue;
|
||||
if (curve.length !== 79) throw new Error(`${classId}/${slot} must cover levels 1–79.`);
|
||||
let previousShieldDefense = 0;
|
||||
classProfile[slot] = curve.map((entry, index) => {
|
||||
const weaponDamage = entry.stats?.weaponDamage ?? 0;
|
||||
const magicalWeaponDamage = entry.stats?.magicalWeaponDamage ?? 0;
|
||||
const speedTenths = entry.speedTenths ?? null;
|
||||
const referenceItems = entry.referenceItemIds.map((id) => itemById.get(id));
|
||||
const weaponType = referenceItems.length && referenceItems.every((item) => item?.sourceCatalog === "weaponobject.db"
|
||||
&& item.type === referenceItems[0].type) ? referenceItems[0].type : null;
|
||||
const shields = referenceItems.length && referenceItems.every((item) => item?.sourceCatalog === "armorobject.db"
|
||||
&& item.type === 5) ? [...referenceItems].sort((left, right) => left.level - right.level) : [];
|
||||
if (shields.length) {
|
||||
const level = index + 1;
|
||||
const lower = shields[0];
|
||||
const upper = shields.at(-1);
|
||||
const blend = level < lower.level ? level / lower.level : level > upper.level ? level / upper.level
|
||||
: lower.level === upper.level ? 0 : (level - lower.level) / (upper.level - lower.level);
|
||||
const rawDefense = level < lower.level ? (upper.baseStats.physicalDefense ?? 0) * blend
|
||||
: level > upper.level ? (lower.baseStats.physicalDefense ?? 0) * blend
|
||||
: (lower.baseStats.physicalDefense ?? 0) * (1 - blend) + (upper.baseStats.physicalDefense ?? 0) * blend;
|
||||
previousShieldDefense = Math.max(previousShieldDefense, rawDefense > 0 ? Math.max(1, Math.round(rawDefense)) : 0);
|
||||
}
|
||||
if (![weaponDamage, magicalWeaponDamage].every(Number.isFinite)
|
||||
|| (speedTenths !== null && !Number.isFinite(speedTenths))) {
|
||||
throw new Error(`Non-finite source training weapon row ${classId}/${slot}/${index + 1}.`);
|
||||
}
|
||||
return {
|
||||
level: index + 1,
|
||||
sourceItemIds: entry.referenceItemIds,
|
||||
weaponDamage,
|
||||
magicalWeaponDamage,
|
||||
speedTenths,
|
||||
weaponType,
|
||||
...(shields.length ? { shieldDefense: previousShieldDefense } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
profiles[classId] = classProfile;
|
||||
}
|
||||
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
status: "compact source-selected training weapon references; simulator gear profile and level interpolation policy",
|
||||
source: {
|
||||
file: "src/game/generated/romHeirloomReference.json",
|
||||
sha256: sha256(inputBuffer),
|
||||
sourceItemDatabases: catalog.sourceFiles,
|
||||
quality: catalog.quality,
|
||||
levelCap: 79,
|
||||
selectedItemCount: selectedItems.length,
|
||||
// Selection excludes unmapped effects, including typed MPow/Resist/AllRange.
|
||||
// These are item contributions only; actor auras and racial/source base inputs remain separate.
|
||||
trainingItemBodyDefaults: {
|
||||
perSchoolMagicPowerRate: 1, perSchoolResistance: 0, perSchoolResistanceRate: 1,
|
||||
allRangeDamageRate: 1, throwDamageRate: 1, throwingWeaponDamage: 0,
|
||||
},
|
||||
},
|
||||
profiles,
|
||||
};
|
||||
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
||||
console.log(`Wrote compact native training weapon profiles to ${path.relative(projectRoot, outputPath)}.`);
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDirectory, "../..");
|
||||
const sourceRoot = path.resolve(process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "..", "Runewaker"));
|
||||
const dataRoot = path.join(sourceRoot, "Resource", "data");
|
||||
const magicCollectPath = path.join(dataRoot, "magiccollectobject.db");
|
||||
const magicObjectPath = path.join(dataRoot, "magicobject.db");
|
||||
const magicProcessPath = path.join(sourceRoot, "SourceCodeVS2019", "Server", "NetGameServer", "MainProc", "MagicProc", "MagicProcess.cpp");
|
||||
const objectStructPath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "ObjectStruct.h");
|
||||
const periodicRankSourcePath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "RD_Normal.cxx");
|
||||
const periodicTickSourcePath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "RD_ValueCal.cxx");
|
||||
const catalogPath = path.join(projectRoot, "src", "game", "romPlayerCatalog.generated.json");
|
||||
const nativeStatRulesPath = path.join(projectRoot, "src", "game", "generated", "romNativeStatRules.generated.json");
|
||||
const outputPath = path.join(projectRoot, "src", "game", "generated", "romNativeHolyHealBindings.generated.json");
|
||||
|
||||
const DATA_START = 140;
|
||||
const COUNT_OFFSET = 132;
|
||||
const MAGIC_COLLECT_RECORD_SIZE = 1032;
|
||||
const MAGIC_OBJECT_RECORD_SIZE = 992;
|
||||
const HOLY_HEAL_CALCULATION_TYPE = 5;
|
||||
const HEALING_FORMULA_SOURCE = {
|
||||
orgPower: "DmgPower + DmgPower * DmgPower_SkillLVArg * MagicProc.MagicLv / 100",
|
||||
spellTime: "MagicCol.SpellTime, replaced by 1 when less than 1 or IsFixDmgEffect is set",
|
||||
mind: "MND * 0.3 * SpellTime; if OrgPower * 5 < this value use OrgPower * 6, else add this value",
|
||||
mdmg: "unless IsIgnoreDmgEffect, add MDMG * FIX.Dmg_HealArg once for fixed effects or multiplied by SpellTime otherwise",
|
||||
scale: "multiply by MPow * HealPowerRate, then add HealPoint * SpellTime",
|
||||
target: "unless MagicBase.SettingEx.IgnoreMagicAbsorb, multiply by 1 + HealAbsorbRate / 100 then add HealAbsorb; clamp below zero; undead reverses sign",
|
||||
random: "Power *= 1 - (Atk.Rand - Atk.Rand * 2 * rand() / 0x7fff) / 100",
|
||||
periodicHeal: "RD_Normal::CalAddDotMagicLv snapshots rank from positive magic HP DotMagic: DotTimes=max(1,int(actual EffectTime/Dot.Time)); DotBase=Base*(1+MagicLv*DotArg/100); Power=DotBase+ExRate*MDMG*int(SpellTime)*FIX.Dmg_HealDotArg/DotTimes; then MND cap/add, HealPowerRate and HealPoint*SpellTime/DotTimes, target absorb rate and absorb/DotTimes unless ignored, clamp below zero, then MagicPow(MagicType); buff Power is that helper's correction plus MagicLv. RD_ValueCal::BuffProc tick is int(-Base*(100+DotArg*buff.Power)/100), then AddHP(-Damage).",
|
||||
};
|
||||
|
||||
function sha256(buffer) {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
function int32(buffer, offset) {
|
||||
return buffer.readInt32LE(offset);
|
||||
}
|
||||
|
||||
function number32(buffer, offset) {
|
||||
const value = buffer.readFloatLE(offset);
|
||||
if (!Number.isFinite(value)) throw new Error(`Non-finite source float at 0x${offset.toString(16)}.`);
|
||||
return Number(value.toFixed(6));
|
||||
}
|
||||
|
||||
function records(buffer, recordSize, expectedType, label) {
|
||||
const count = int32(buffer, COUNT_OFFSET);
|
||||
if (count <= 0 || DATA_START + count * recordSize > buffer.length) {
|
||||
throw new Error(`${label} record count or record size does not match this exporter.`);
|
||||
}
|
||||
for (const index of [0, Math.floor(count / 2), count - 1]) {
|
||||
if (int32(buffer, DATA_START + index * recordSize + 4) !== expectedType) {
|
||||
throw new Error(`${label} type sentinel does not match this exporter.`);
|
||||
}
|
||||
}
|
||||
return Array.from({ length: count }, (_, index) => DATA_START + index * recordSize);
|
||||
}
|
||||
|
||||
const [magicCollect, magicObject, magicProcess, objectStruct, periodicRankSource, periodicTickSource, catalogBuffer, nativeStatRulesBuffer] = await Promise.all([
|
||||
readFile(magicCollectPath),
|
||||
readFile(magicObjectPath),
|
||||
readFile(magicProcessPath),
|
||||
readFile(objectStructPath),
|
||||
readFile(periodicRankSourcePath),
|
||||
readFile(periodicTickSourcePath),
|
||||
readFile(catalogPath),
|
||||
readFile(nativeStatRulesPath),
|
||||
]);
|
||||
const catalog = JSON.parse(catalogBuffer.toString("utf8"));
|
||||
const nativeStatRules = JSON.parse(nativeStatRulesBuffer.toString("utf8"));
|
||||
const collectOffsets = new Map(records(magicCollect, MAGIC_COLLECT_RECORD_SIZE, 21, "magiccollectobject.db")
|
||||
.map((offset) => [int32(magicCollect, offset), offset]));
|
||||
const objectOffsets = new Map(records(magicObject, MAGIC_OBJECT_RECORD_SIZE, 3, "magicobject.db")
|
||||
.map((offset) => [int32(magicObject, offset), offset]));
|
||||
|
||||
function rawComponents(collectOffset) {
|
||||
const componentIds = new Set();
|
||||
for (let offset = 0x180; offset <= 0x2c0; offset += 4) {
|
||||
const id = int32(magicCollect, collectOffset + offset);
|
||||
if (objectOffsets.has(id)) componentIds.add(id);
|
||||
}
|
||||
return [...componentIds].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function sourceComponent(componentId) {
|
||||
const offset = objectOffsets.get(componentId);
|
||||
if (offset === undefined) return null;
|
||||
const attackType = int32(magicObject, offset + 0x194);
|
||||
const calculationType = int32(magicObject, offset + 0x2d4);
|
||||
const settingFlags = int32(magicObject, offset + 0xd8);
|
||||
// GameObjectMagicStruct ends with SettingEx (ObjectStruct.h:4131-4132).
|
||||
const settingExFlags = int32(magicObject, offset + MAGIC_OBJECT_RECORD_SIZE - 4);
|
||||
return {
|
||||
componentId,
|
||||
magicFunc: int32(magicObject, offset + 0xb0),
|
||||
magicType: int32(magicObject, offset + 0xb4),
|
||||
effectType: int32(magicObject, offset + 0xb8),
|
||||
effectTimeSeconds: number32(magicObject, offset + 0xbc),
|
||||
levelDeltaEffectTime: number32(magicObject, offset + 0xc0),
|
||||
effectTimeSkillLevelArg: number32(magicObject, offset + 0xc4),
|
||||
settingFlags,
|
||||
isDotMagic: (settingFlags & (1 << 3)) !== 0,
|
||||
isFixDotPower: (settingFlags & (1 << 20)) !== 0,
|
||||
dotTimeSeconds: int32(magicObject, offset + 0x184),
|
||||
dotType: int32(magicObject, offset + 0x188),
|
||||
dotBase: int32(magicObject, offset + 0x18c),
|
||||
dotSkillLevelArg: number32(magicObject, offset + 0x190),
|
||||
attackType,
|
||||
calculationType,
|
||||
damagePower: number32(magicObject, offset + 0x198),
|
||||
damagePowerSkillLevelArg: number32(magicObject, offset + 0x19c),
|
||||
fixedValue: number32(magicObject, offset + 0x1a0),
|
||||
fixedType: int32(magicObject, offset + 0x1a4),
|
||||
fixedDamageSkillLevelArg: number32(magicObject, offset + 0x1a8),
|
||||
randomRange: number32(magicObject, offset + 0x1ac),
|
||||
criticalRate: int32(magicObject, offset + 0x1b0),
|
||||
ignoreMagicAbsorb: (settingExFlags & 1) !== 0,
|
||||
rawSettingExFlags: settingExFlags,
|
||||
};
|
||||
}
|
||||
|
||||
const attackBindings = [];
|
||||
const periodicHealBindings = [];
|
||||
for (const skill of catalog.skills ?? []) {
|
||||
const ranks = skill.ranks?.length ? skill.ranks : [{ level: skill.unlockLevel, sourceSkillId: skill.sourceSkillId }];
|
||||
const directRankRows = [];
|
||||
const dotHealRankRows = [];
|
||||
for (const rank of ranks) {
|
||||
const sourceSkillId = Number(rank.sourceSkillId ?? skill.sourceSkillId);
|
||||
const collectOffset = collectOffsets.get(sourceSkillId);
|
||||
if (collectOffset === undefined) continue;
|
||||
const collectRefs = rawComponents(collectOffset);
|
||||
const collectFlags = int32(magicCollect, collectOffset + 0x110);
|
||||
const collectMode = int32(magicCollect, collectOffset + 0x368);
|
||||
const rankRow = {
|
||||
rank: rank.rank ?? null,
|
||||
unlockLevel: rank.level ?? skill.unlockLevel,
|
||||
sourceSkillId,
|
||||
configuredCollectMagicLevel: int32(magicCollect, collectOffset + 0xb0),
|
||||
spellTimeSeconds: number32(magicCollect, collectOffset + 0x114),
|
||||
nextSpellTimeSeconds: number32(magicCollect, collectOffset + 0x120),
|
||||
spellCount: int32(magicCollect, collectOffset + 0x11c),
|
||||
rangeType: int32(magicCollect, collectOffset + 0xc4),
|
||||
decreaseArg: int32(magicCollect, collectOffset + 0xd0),
|
||||
rawCollectTimingFlags: collectFlags,
|
||||
referenceWeaponBowSpellTime: (collectFlags & (1 << 13)) !== 0,
|
||||
referenceWeaponThrowSpellTime: (collectFlags & (1 << 14)) !== 0,
|
||||
rawCollectMode: collectMode,
|
||||
isIgnoreDmgEffect: (collectMode & (1 << 15)) !== 0,
|
||||
isFixDmgEffect: (collectMode & (1 << 16)) !== 0,
|
||||
isShareDamage: (collectMode & (1 << 19)) !== 0,
|
||||
};
|
||||
const directComponents = [];
|
||||
const dotHealComponents = [];
|
||||
for (const componentId of collectRefs) {
|
||||
const component = sourceComponent(componentId);
|
||||
if (!component) continue;
|
||||
if (component.magicFunc === 0 && component.attackType === 0) directComponents.push(component);
|
||||
if (component.isDotMagic && component.effectType === 0 && component.dotType === 0
|
||||
&& component.dotTimeSeconds > 0 && component.dotBase > 0) {
|
||||
dotHealComponents.push(component);
|
||||
}
|
||||
}
|
||||
if (directComponents.length) directRankRows.push({ ...rankRow, componentIds: directComponents });
|
||||
if (dotHealComponents.length) dotHealRankRows.push({ ...rankRow, componentIds: dotHealComponents });
|
||||
}
|
||||
const skillBase = {
|
||||
abilityId: skill.id,
|
||||
classId: skill.classId,
|
||||
...(skill.secondaryClassId ? { secondaryClassId: skill.secondaryClassId } : {}),
|
||||
group: skill.group,
|
||||
unlockLevel: skill.unlockLevel,
|
||||
name: skill.name,
|
||||
};
|
||||
if (directRankRows.length) attackBindings.push({ ...skillBase, ranks: directRankRows });
|
||||
if (dotHealRankRows.length) periodicHealBindings.push({ ...skillBase, ranks: dotHealRankRows });
|
||||
}
|
||||
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
source: {
|
||||
magicCollectDatabase: "Resource/data/magiccollectobject.db",
|
||||
magicCollectSha256: sha256(magicCollect),
|
||||
magicObjectDatabase: "Resource/data/magicobject.db",
|
||||
magicObjectSha256: sha256(magicObject),
|
||||
magicProcessSource: "SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
|
||||
magicProcessSha256: sha256(magicProcess),
|
||||
objectStructSource: "SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h",
|
||||
objectStructSha256: sha256(objectStruct),
|
||||
periodicRankSource: "SourceCodeVS2019/Libraries/Union/RoleData/RD_Normal.cxx",
|
||||
periodicRankSourceSha256: sha256(periodicRankSource),
|
||||
periodicTickSource: "SourceCodeVS2019/Libraries/Union/RoleData/RD_ValueCal.cxx",
|
||||
periodicTickSourceSha256: sha256(periodicTickSource),
|
||||
playerCatalog: "src/game/romPlayerCatalog.generated.json",
|
||||
playerCatalogSha256: sha256(catalogBuffer),
|
||||
nativeStatRules: "src/game/generated/romNativeStatRules.generated.json",
|
||||
nativeStatRulesSha256: sha256(nativeStatRulesBuffer),
|
||||
},
|
||||
recordLayout: {
|
||||
magicCollect: { offset: DATA_START, recordSize: MAGIC_COLLECT_RECORD_SIZE, type: 21, magicColBaseOffset: 0xb0 },
|
||||
magicObject: { offset: DATA_START, recordSize: MAGIC_OBJECT_RECORD_SIZE, type: 3 },
|
||||
magicCollectSpellTimeOffset: 0x114,
|
||||
magicCollectFlagsOffset: 0x110,
|
||||
magicCollectTimingFlagsBowSpellTimeBit: 13,
|
||||
magicCollectTimingFlagsThrowSpellTimeBit: 14,
|
||||
magicObjectAttackPowerOffset: 0x198,
|
||||
magicObjectAttackPowerSkillLevelArgOffset: 0x19c,
|
||||
magicObjectAttackCalculationTypeOffset: 0x2d4,
|
||||
magicObjectSettingFlagsOffset: 0xd8,
|
||||
magicObjectDotMagicEnabledBit: 3,
|
||||
magicObjectFixDotPowerBit: 20,
|
||||
magicObjectEffectTimeOffset: 0xbc,
|
||||
magicObjectLevelDeltaEffectTimeOffset: 0xc0,
|
||||
magicObjectEffectTimeSkillLevelArgOffset: 0xc4,
|
||||
magicObjectDotTimeOffset: 0x184,
|
||||
magicObjectDotTypeOffset: 0x188,
|
||||
magicObjectDotBaseOffset: 0x18c,
|
||||
magicObjectDotSkillLevelArgOffset: 0x190,
|
||||
magicObjectSettingExFlagsOffset: MAGIC_OBJECT_RECORD_SIZE - 4,
|
||||
magicObjectIgnoreMagicAbsorbBit: 0,
|
||||
magicCollectModeOffset: 0x368,
|
||||
magicCollectModeIgnoreDamageBit: 15,
|
||||
magicCollectModeFixedDamageBit: 16,
|
||||
magicCollectModeShareDamageBit: 19,
|
||||
},
|
||||
formula: {
|
||||
classification: "source-backed local RuneWaker HolyHeal resolver; no Chronicles of Arcadia values",
|
||||
magicLevel: "MagicProc.MagicLv is a required runtime input; the source catalog does not supply a universal rank scalar",
|
||||
fixHealMagicDamageCoefficient: 0.5,
|
||||
fixHealingDotMagicDamageCoefficient: nativeStatRules.playerFix.healingDotMagicalDamageCoefficient,
|
||||
periodicMagicLevel: "CalAddDotMagicLv returns 0 when FixDotPower is set or DotSkillLVArg is 0; otherwise the caster computes a source correction and adds it to MagicProc.MagicLv at buff application. The tick helper consumes that frozen Buff.Power.",
|
||||
periodicSourceActorPolicy: "Runewaker uses its player ExRate only for IsPlayer() owners; this simulator treats both active player and player-build party identities as source-player healing owners.",
|
||||
expressions: HEALING_FORMULA_SOURCE,
|
||||
},
|
||||
periodicHealBindings,
|
||||
attackBindings,
|
||||
holyHealBindings: attackBindings.flatMap((ability) => ability.ranks.flatMap((rank) => {
|
||||
const components = rank.componentIds.filter((component) => component.calculationType === HOLY_HEAL_CALCULATION_TYPE);
|
||||
return components.length ? [{ ...ability, ranks: [{ ...rank, componentIds: components }] }] : [];
|
||||
})),
|
||||
};
|
||||
|
||||
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
||||
console.log(`Wrote ${attackBindings.length} source attack bindings, ${output.holyHealBindings.length} HolyHeal bindings, and ${periodicHealBindings.length} positive HP HoT bindings to ${path.relative(projectRoot, outputPath)}.`);
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { projectRoot, readJson, sourceRootFor } from "./lib/recipe.mjs";
|
||||
|
||||
const DATA_START = 140;
|
||||
const NPC_RECORD_SIZE = 2224;
|
||||
const ITEM_RECORD_SIZE = 1776;
|
||||
const REFINE_RECORD_SIZE = 280;
|
||||
const NPC_OBJECT_TYPE = 1;
|
||||
const ITEM_OBJECT_TYPE = 2;
|
||||
const REFINE_OBJECT_TYPE = 22;
|
||||
const NPC_EQUIPMENT = Object.freeze({
|
||||
mainHand: { start: 412, count: 5 },
|
||||
offHand: { start: 432, count: 5 },
|
||||
bow: { start: 1260, count: 1 },
|
||||
ornament: { start: 1648, count: 1 },
|
||||
});
|
||||
const ITEM_FIELDS = Object.freeze({
|
||||
durable: 0x54,
|
||||
quality: 0x60,
|
||||
level: 0x78,
|
||||
gearType: 0xd0,
|
||||
gearPosition: 0xd4,
|
||||
eqTypes: 0xd8,
|
||||
eqValues: 0x100,
|
||||
dropAbilities: 0x134,
|
||||
attackRange: 0x1ac,
|
||||
attackSpeed: 0x1b0,
|
||||
refineTableId: 0x3d0,
|
||||
refineLevel: ITEM_RECORD_SIZE - 12,
|
||||
addPowerQuality: ITEM_RECORD_SIZE - 8,
|
||||
collectionNpc: ITEM_RECORD_SIZE - 4,
|
||||
});
|
||||
const REFINE_FIELDS = Object.freeze({
|
||||
type: 0xb4,
|
||||
level: 0xb8,
|
||||
eqTypes: 0xbc,
|
||||
eqValues: 0xe4,
|
||||
exEqPowerRate: 0x114,
|
||||
});
|
||||
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const int32 = (bytes, offset) => bytes.readInt32LE(offset);
|
||||
const checkTable = (bytes, recordSize, objectType, label) => {
|
||||
const count = int32(bytes, 132);
|
||||
if (!Number.isInteger(count) || count <= 0 || DATA_START + count * recordSize > bytes.length) {
|
||||
throw new Error(`${label} fixed-record layout changed.`);
|
||||
}
|
||||
for (const index of [0, Math.floor(count / 2), count - 1]) {
|
||||
if (int32(bytes, DATA_START + index * recordSize + 4) !== objectType) {
|
||||
throw new Error(`${label} object type sentinel changed.`);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const recipeDirectory = path.join(projectRoot, "scripts/runewaker-pipeline/recipes");
|
||||
const recipes = (await readdir(recipeDirectory)).filter((name) => name.endsWith("-population.json")).sort();
|
||||
if (!recipes.length) throw new Error("No population recipes found.");
|
||||
const recipe = await readJson(path.join(recipeDirectory, recipes[0]));
|
||||
const sourceRoot = sourceRootFor(recipe);
|
||||
const dataRoot = path.join(sourceRoot, recipe.source.resourceRoot ?? "Resource", "data");
|
||||
const filenames = ["npcobject.db", "armorobject.db", "weaponobject.db", "eqrefineabilityobject.db"];
|
||||
const buffers = Object.fromEntries(await Promise.all(filenames.map(async (filename) => (
|
||||
[filename, await readFile(path.join(dataRoot, filename))]
|
||||
))));
|
||||
const npcCount = checkTable(buffers["npcobject.db"], NPC_RECORD_SIZE, NPC_OBJECT_TYPE, "NPCObjectDB");
|
||||
const itemCounts = Object.fromEntries(["armorobject.db", "weaponobject.db"].map((filename) => (
|
||||
[filename, checkTable(buffers[filename], ITEM_RECORD_SIZE, ITEM_OBJECT_TYPE, filename)]
|
||||
)));
|
||||
const refineCount = checkTable(buffers["eqrefineabilityobject.db"], REFINE_RECORD_SIZE, REFINE_OBJECT_TYPE, "EqRefineAbilityObjectDB");
|
||||
|
||||
const nativeCombatData = await readJson(path.join(projectRoot, "src/game/generated/romNativeCombatData.generated.json"));
|
||||
if (!Array.isArray(nativeCombatData.npcs) || nativeCombatData.npcs.length === 0) {
|
||||
throw new Error("Native combat NPC catalog is empty.");
|
||||
}
|
||||
const npcSourceOffsets = new Map();
|
||||
for (let index = 0; index < npcCount; index += 1) {
|
||||
const offset = DATA_START + index * NPC_RECORD_SIZE;
|
||||
if (int32(buffers["npcobject.db"], offset + 4) !== NPC_OBJECT_TYPE) continue;
|
||||
const npcId = int32(buffers["npcobject.db"], offset);
|
||||
if (npcId > 0) npcSourceOffsets.set(npcId, offset);
|
||||
}
|
||||
|
||||
const sourceItemsById = new Map();
|
||||
for (const filename of ["armorobject.db", "weaponobject.db"]) {
|
||||
const bytes = buffers[filename];
|
||||
for (let index = 0; index < itemCounts[filename]; index += 1) {
|
||||
const offset = DATA_START + index * ITEM_RECORD_SIZE;
|
||||
if (int32(bytes, offset + 4) !== ITEM_OBJECT_TYPE) throw new Error(`${filename} object type changed at ${index}.`);
|
||||
const itemId = int32(bytes, offset);
|
||||
if (itemId <= 0) continue;
|
||||
if (sourceItemsById.has(itemId)) throw new Error(`Duplicate source item ID ${itemId} across equipment catalogs.`);
|
||||
const eqPairs = Array.from({ length: 10 }, (_, slot) => ({
|
||||
slot,
|
||||
eqType: int32(bytes, offset + ITEM_FIELDS.eqTypes + slot * 4),
|
||||
value: int32(bytes, offset + ITEM_FIELDS.eqValues + slot * 4),
|
||||
}));
|
||||
const dropAbilityPairs = Array.from({ length: 6 }, (_, slot) => ({
|
||||
slot,
|
||||
attributeId: int32(bytes, offset + ITEM_FIELDS.dropAbilities + slot * 8),
|
||||
rate: int32(bytes, offset + ITEM_FIELDS.dropAbilities + slot * 8 + 4),
|
||||
}));
|
||||
sourceItemsById.set(itemId, {
|
||||
sourceItemId: itemId,
|
||||
sourceCatalog: filename,
|
||||
sourceRecordIndex: index,
|
||||
objectKind: "equipment-item",
|
||||
objectType: int32(bytes, offset + 4),
|
||||
quality: int32(bytes, offset + ITEM_FIELDS.quality),
|
||||
itemLevel: int32(bytes, offset + ITEM_FIELDS.level),
|
||||
gearType: int32(bytes, offset + ITEM_FIELDS.gearType),
|
||||
gearPosition: int32(bytes, offset + ITEM_FIELDS.gearPosition),
|
||||
eqTypeValuePairs: eqPairs,
|
||||
dropAbilityIdRatePairs: dropAbilityPairs,
|
||||
durable: int32(bytes, offset + ITEM_FIELDS.durable),
|
||||
refineTableId: int32(bytes, offset + ITEM_FIELDS.refineTableId),
|
||||
attackRange: filename === "weaponobject.db" ? int32(bytes, offset + ITEM_FIELDS.attackRange) : null,
|
||||
attackSpeed: filename === "weaponobject.db" ? int32(bytes, offset + ITEM_FIELDS.attackSpeed) : null,
|
||||
refineLevel: int32(bytes, offset + ITEM_FIELDS.refineLevel),
|
||||
addPowerQuality: int32(bytes, offset + ITEM_FIELDS.addPowerQuality),
|
||||
collectionNpcId: int32(bytes, offset + ITEM_FIELDS.collectionNpc),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const referencedItemIds = new Set();
|
||||
const npcs = nativeCombatData.npcs.map((catalogNpc) => {
|
||||
const offset = npcSourceOffsets.get(catalogNpc.npcId);
|
||||
if (offset === undefined) throw new Error(`Missing NPCObjectDB row ${catalogNpc.npcId}.`);
|
||||
const bytes = buffers["npcobject.db"];
|
||||
const candidates = (slot) => Array.from({ length: slot.count }, (_, candidateIndex) => {
|
||||
const sourceItemId = int32(bytes, offset + slot.start + candidateIndex * 4);
|
||||
if (sourceItemId > 0) referencedItemIds.add(sourceItemId);
|
||||
return { candidateIndex, sourceItemId, selectionWeightPercent: 20 };
|
||||
});
|
||||
const bowEquipmentId = int32(bytes, offset + NPC_EQUIPMENT.bow.start);
|
||||
const ornamentEquipmentId = int32(bytes, offset + NPC_EQUIPMENT.ornament.start);
|
||||
if (bowEquipmentId > 0) referencedItemIds.add(bowEquipmentId);
|
||||
if (ornamentEquipmentId > 0) referencedItemIds.add(ornamentEquipmentId);
|
||||
return {
|
||||
npcId: catalogNpc.npcId,
|
||||
sourceRecordIndex: (offset - DATA_START) / NPC_RECORD_SIZE,
|
||||
mainHandCandidates: candidates(NPC_EQUIPMENT.mainHand),
|
||||
offHandCandidates: candidates(NPC_EQUIPMENT.offHand),
|
||||
bowEquipmentId,
|
||||
ornamentEquipmentId,
|
||||
};
|
||||
});
|
||||
|
||||
const referencedRefineIds = new Set();
|
||||
for (const itemId of referencedItemIds) {
|
||||
const item = sourceItemsById.get(itemId);
|
||||
if (!item || item.refineLevel <= 0) continue;
|
||||
const level = Math.min(item.refineLevel, 20);
|
||||
for (let refineLevel = 1; refineLevel <= level; refineLevel += 1) {
|
||||
referencedRefineIds.add(item.refineTableId + refineLevel - 1);
|
||||
}
|
||||
}
|
||||
const refineById = new Map();
|
||||
const refineBytes = buffers["eqrefineabilityobject.db"];
|
||||
for (let index = 0; index < refineCount; index += 1) {
|
||||
const offset = DATA_START + index * REFINE_RECORD_SIZE;
|
||||
if (int32(refineBytes, offset + 4) !== REFINE_OBJECT_TYPE) {
|
||||
throw new Error(`eqrefineabilityobject.db object type changed at ${index}.`);
|
||||
}
|
||||
const id = int32(refineBytes, offset);
|
||||
if (!referencedRefineIds.has(id)) continue;
|
||||
refineById.set(id, {
|
||||
refineObjectId: id,
|
||||
sourceRecordIndex: index,
|
||||
refineType: int32(refineBytes, offset + REFINE_FIELDS.type),
|
||||
refineLevel: int32(refineBytes, offset + REFINE_FIELDS.level),
|
||||
eqTypeValuePairs: Array.from({ length: 10 }, (_, slot) => ({
|
||||
slot,
|
||||
eqType: int32(refineBytes, offset + REFINE_FIELDS.eqTypes + slot * 4),
|
||||
value: int32(refineBytes, offset + REFINE_FIELDS.eqValues + slot * 4),
|
||||
})),
|
||||
exEqPowerRate: int32(refineBytes, offset + REFINE_FIELDS.exEqPowerRate),
|
||||
});
|
||||
}
|
||||
const refineItemsById = Object.fromEntries([...referencedRefineIds].sort((left, right) => left - right)
|
||||
.map((id) => [id, refineById.get(id) ?? null]));
|
||||
const unresolvedRefineIds = Object.entries(refineItemsById).filter(([, item]) => item === null).map(([id]) => Number(id));
|
||||
|
||||
const itemsById = {};
|
||||
for (const itemId of [...referencedItemIds].sort((left, right) => left - right)) {
|
||||
itemsById[itemId] = sourceItemsById.get(itemId) ?? null;
|
||||
}
|
||||
const unresolvedItemIds = Object.entries(itemsById).filter(([, item]) => item === null).map(([id]) => Number(id));
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
source: {
|
||||
root: path.relative(projectRoot, sourceRoot).split(path.sep).join("/"),
|
||||
files: Object.fromEntries(filenames.map((filename) => [filename, {
|
||||
sha256: sha256(buffers[filename]),
|
||||
recordStart: DATA_START,
|
||||
recordSize: filename === "npcobject.db" ? NPC_RECORD_SIZE
|
||||
: filename === "eqrefineabilityobject.db" ? REFINE_RECORD_SIZE : ITEM_RECORD_SIZE,
|
||||
recordCount: filename === "npcobject.db" ? npcCount
|
||||
: filename === "eqrefineabilityobject.db" ? refineCount : itemCounts[filename],
|
||||
}])),
|
||||
npcEquipmentOffsets: NPC_EQUIPMENT,
|
||||
itemFieldOffsets: ITEM_FIELDS,
|
||||
refineRecordSize: REFINE_RECORD_SIZE,
|
||||
refineFieldOffsets: REFINE_FIELDS,
|
||||
npcWearDefaults: { fieldDurable: 10000, fieldQuality: 100, fieldPowerQuality: 10, fieldRare: 0, inherentIds: [], runeIds: [] },
|
||||
candidateRule: "RD_Setting.cxx selects MainHandEQ and SecHandEQ with rand() % 5; preserve every slot and duplicate at 20 percent each.",
|
||||
simulatorSelectionPolicy: "Stable weighted selection over the source candidate slots using a caller-provided actor seed (or npcId default); each preserved 20-percent slot remains an independent source outcome and no item-stat averaging is performed.",
|
||||
cxxSource: "Runewaker/SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h:GameObjectNpcStruct",
|
||||
trailingItemFields: "ObjectStruct.h GameObjectItemStruct ends with RefineLv, AddPowerQuality, CollectionNPC; each is int32, so the 1776-byte fixed item row maps them to offsets 1764, 1768, 1772 respectively.",
|
||||
itemOffset134Meaning: "GameObjectItemStruct::DropItemAbility[] (drop metadata, not equipment affixes). NPC spawn initialization does not populate runtime ItemFieldStruct inherent or rune slots.",
|
||||
refineSource: "RoleDataEx::WearEQ reads RefineLv (capped at 20) and RoleDataEx::GetRefinAblility resolves Item.RefineTableID + level - 1.",
|
||||
unresolvedItemIds,
|
||||
unresolvedRefineIds,
|
||||
},
|
||||
npcs,
|
||||
itemsById,
|
||||
refineItemsById,
|
||||
};
|
||||
const outputPath = path.join(projectRoot, "src/game/generated/romNativeNpcEquipment.generated.json");
|
||||
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
||||
console.log(`Wrote ${npcs.length} NPC rows and ${Object.keys(itemsById).length} referenced items to ${path.relative(projectRoot, outputPath)}.`);
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDirectory, "../..");
|
||||
const sourceRoot = path.resolve(process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "..", "Runewaker"));
|
||||
const dataRoot = path.join(sourceRoot, "Resource", "data");
|
||||
const magicCollectPath = path.join(dataRoot, "magiccollectobject.db");
|
||||
const magicObjectPath = path.join(dataRoot, "magicobject.db");
|
||||
const magicProcessPath = path.join(sourceRoot, "SourceCodeVS2019", "Server", "NetGameServer", "MainProc", "MagicProc", "MagicProcess.cpp");
|
||||
const objectStructPath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "ObjectStruct.h");
|
||||
const periodicRankSourcePath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "RD_Normal.cxx");
|
||||
const periodicTickSourcePath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "RD_ValueCal.cxx");
|
||||
const catalogPath = path.join(projectRoot, "src", "game", "romPlayerCatalog.generated.json");
|
||||
const nativeStatRulesPath = path.join(projectRoot, "src", "game", "generated", "romNativeStatRules.generated.json");
|
||||
const nativeCombatDataPath = path.join(projectRoot, "src", "game", "generated", "romNativeCombatData.generated.json");
|
||||
const outputPath = path.join(projectRoot, "src", "game", "generated", "romNativePeriodicDamageBindings.generated.json");
|
||||
|
||||
const DATA_START = 140;
|
||||
const COUNT_OFFSET = 132;
|
||||
const MAGIC_COLLECT_RECORD_SIZE = 1032;
|
||||
const MAGIC_OBJECT_RECORD_SIZE = 992;
|
||||
const ACTIVE_PAIRS = Object.freeze({
|
||||
"rom-knight": "rom-scout",
|
||||
"rom-priest": "rom-knight",
|
||||
"rom-rogue": "rom-warrior",
|
||||
"rom-scout": "rom-rogue",
|
||||
"rom-mage": "rom-warlock",
|
||||
});
|
||||
const COMPONENT_FILTER = Object.freeze({
|
||||
dotMagicFlagBit: 3,
|
||||
showPowerLevelBit: 14,
|
||||
dotMagicNoKillBit: 15,
|
||||
maxMagicLevelTargetBoundBit: 17,
|
||||
fixDotPowerBit: 20,
|
||||
ignoreMagicAbsorbBit: 0,
|
||||
healthDotType: 0,
|
||||
percentHealthDotType: 5,
|
||||
});
|
||||
const NATIVE_NPC_GROWTH_LEVEL_MAX = 200;
|
||||
|
||||
function sha256(buffer) {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
function int32(buffer, offset) {
|
||||
return buffer.readInt32LE(offset);
|
||||
}
|
||||
|
||||
function number32(buffer, offset) {
|
||||
const value = buffer.readFloatLE(offset);
|
||||
if (!Number.isFinite(value)) throw new Error(`Non-finite source float at 0x${offset.toString(16)}.`);
|
||||
return Number(value.toFixed(6));
|
||||
}
|
||||
|
||||
function records(buffer, recordSize, expectedType, label) {
|
||||
const count = int32(buffer, COUNT_OFFSET);
|
||||
if (count <= 0 || DATA_START + count * recordSize > buffer.length) {
|
||||
throw new Error(`${label} record count or record size does not match this exporter.`);
|
||||
}
|
||||
for (const index of [0, Math.floor(count / 2), count - 1]) {
|
||||
if (int32(buffer, DATA_START + index * recordSize + 4) !== expectedType) {
|
||||
throw new Error(`${label} type sentinel does not match this exporter.`);
|
||||
}
|
||||
}
|
||||
return Array.from({ length: count }, (_, index) => DATA_START + index * recordSize);
|
||||
}
|
||||
|
||||
const [magicCollect, magicObject, magicProcess, objectStruct, periodicRankSource, periodicTickSource, catalogBuffer, nativeStatRulesBuffer, nativeCombatDataBuffer] = await Promise.all([
|
||||
readFile(magicCollectPath),
|
||||
readFile(magicObjectPath),
|
||||
readFile(magicProcessPath),
|
||||
readFile(objectStructPath),
|
||||
readFile(periodicRankSourcePath),
|
||||
readFile(periodicTickSourcePath),
|
||||
readFile(catalogPath),
|
||||
readFile(nativeStatRulesPath),
|
||||
readFile(nativeCombatDataPath),
|
||||
]);
|
||||
const catalog = JSON.parse(catalogBuffer.toString("utf8"));
|
||||
const nativeStatRules = JSON.parse(nativeStatRulesBuffer.toString("utf8"));
|
||||
const nativeCombatData = JSON.parse(nativeCombatDataBuffer.toString("utf8"));
|
||||
const collectOffsets = new Map(records(magicCollect, MAGIC_COLLECT_RECORD_SIZE, 21, "magiccollectobject.db")
|
||||
.map((offset) => [int32(magicCollect, offset), offset]));
|
||||
const objectOffsets = new Map(records(magicObject, MAGIC_OBJECT_RECORD_SIZE, 3, "magicobject.db")
|
||||
.map((offset) => [int32(magicObject, offset), offset]));
|
||||
|
||||
function rawComponents(collectOffset) {
|
||||
const componentIds = new Set();
|
||||
for (let offset = 0x180; offset <= 0x2c0; offset += 4) {
|
||||
const id = int32(magicCollect, collectOffset + offset);
|
||||
if (objectOffsets.has(id)) componentIds.add(id);
|
||||
}
|
||||
return [...componentIds].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function sourceDotComponent(componentId) {
|
||||
const offset = objectOffsets.get(componentId);
|
||||
if (offset === undefined) return null;
|
||||
const settingFlags = int32(magicObject, offset + 0xd8);
|
||||
const settingExFlags = int32(magicObject, offset + MAGIC_OBJECT_RECORD_SIZE - 4);
|
||||
const dotType = int32(magicObject, offset + 0x188);
|
||||
const dotBase = int32(magicObject, offset + 0x18c);
|
||||
const dotTimeSeconds = int32(magicObject, offset + 0x184);
|
||||
if ((settingFlags & (1 << COMPONENT_FILTER.dotMagicFlagBit)) === 0
|
||||
|| dotTimeSeconds <= 0 || dotBase >= 0
|
||||
|| ![COMPONENT_FILTER.healthDotType, COMPONENT_FILTER.percentHealthDotType].includes(dotType)) return null;
|
||||
return {
|
||||
kind: "dot",
|
||||
componentId,
|
||||
magicFunc: int32(magicObject, offset + 0xb0),
|
||||
magicType: int32(magicObject, offset + 0xb4),
|
||||
effectType: int32(magicObject, offset + 0xb8),
|
||||
effectTimeSeconds: number32(magicObject, offset + 0xbc),
|
||||
levelDeltaEffectTime: number32(magicObject, offset + 0xc0),
|
||||
effectTimeSkillLevelArg: number32(magicObject, offset + 0xc4),
|
||||
maxBuffLevelBase: int32(magicObject, offset + 0xcc),
|
||||
maxBuffLevelSkillArg: number32(magicObject, offset + 0xd0),
|
||||
settingFlags,
|
||||
isDotMagic: true,
|
||||
isShowPowerLevel: (settingFlags & (1 << COMPONENT_FILTER.showPowerLevelBit)) !== 0,
|
||||
isDotMagicNoKill: (settingFlags & (1 << COMPONENT_FILTER.dotMagicNoKillBit)) !== 0,
|
||||
isMaxMagicLevelTargetBound: (settingFlags & (1 << COMPONENT_FILTER.maxMagicLevelTargetBoundBit)) !== 0,
|
||||
isFixDotPower: (settingFlags & (1 << COMPONENT_FILTER.fixDotPowerBit)) !== 0,
|
||||
dotTimeSeconds,
|
||||
dotType,
|
||||
dotBase,
|
||||
dotSkillLevelArg: number32(magicObject, offset + 0x190),
|
||||
buffMaxLevel: int32(magicObject, offset + 0x3b4),
|
||||
isIgnoreMagicAbsorb: (settingExFlags & (1 << COMPONENT_FILTER.ignoreMagicAbsorbBit)) !== 0,
|
||||
rawSettingExFlags: settingExFlags,
|
||||
};
|
||||
}
|
||||
|
||||
const activeDamageBindings = [];
|
||||
for (const skill of catalog.skills ?? []) {
|
||||
const expectedSecondary = ACTIVE_PAIRS[skill.classId];
|
||||
if (!expectedSecondary || (skill.secondaryClassId && skill.secondaryClassId !== expectedSecondary)) continue;
|
||||
const ranks = skill.ranks?.length ? skill.ranks : [{ level: skill.unlockLevel, sourceSkillId: skill.sourceSkillId }];
|
||||
const dotRanks = [];
|
||||
for (const rank of ranks) {
|
||||
const sourceSkillId = Number(rank.sourceSkillId ?? skill.sourceSkillId);
|
||||
const collectOffset = collectOffsets.get(sourceSkillId);
|
||||
if (collectOffset === undefined) continue;
|
||||
const components = rawComponents(collectOffset).map(sourceDotComponent).filter(Boolean);
|
||||
if (!components.length) continue;
|
||||
const collectMode = int32(magicCollect, collectOffset + 0x368);
|
||||
dotRanks.push({
|
||||
rank: rank.rank ?? null,
|
||||
unlockLevel: rank.level ?? skill.unlockLevel,
|
||||
sourceSkillId,
|
||||
configuredCollectMagicLevel: int32(magicCollect, collectOffset + 0xb0),
|
||||
rawSpellTimeSeconds: number32(magicCollect, collectOffset + 0x114),
|
||||
rawCollectMode: collectMode,
|
||||
isFixDmgEffect: (collectMode & (1 << 16)) !== 0,
|
||||
componentIds: components,
|
||||
});
|
||||
}
|
||||
if (!dotRanks.length) continue;
|
||||
activeDamageBindings.push({
|
||||
abilityId: skill.id,
|
||||
classId: skill.classId,
|
||||
...(skill.secondaryClassId ? { secondaryClassId: skill.secondaryClassId } : {}),
|
||||
group: skill.group,
|
||||
unlockLevel: skill.unlockLevel,
|
||||
semanticKind: "dot",
|
||||
sourceSkillIds: dotRanks.map((rank) => rank.sourceSkillId),
|
||||
ranks: dotRanks,
|
||||
});
|
||||
}
|
||||
|
||||
function importedEnemyDotComponent(component) {
|
||||
if (component.magicFunc !== 1 || component.isDotMagic !== true || component.dotTimeSeconds <= 0
|
||||
|| component.dotBase >= 0 || ![COMPONENT_FILTER.healthDotType, COMPONENT_FILTER.percentHealthDotType].includes(component.dotType)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "dot",
|
||||
componentId: component.componentId,
|
||||
magicFunc: component.magicFunc,
|
||||
magicType: component.magicType,
|
||||
effectType: component.effectType,
|
||||
effectTimeSeconds: component.effectTimeSeconds,
|
||||
levelDeltaEffectTime: component.levelDeltaEffectTime,
|
||||
effectTimeSkillLevelArg: component.effectTimeSkillLevelArg,
|
||||
maxBuffLevelBase: component.maxBuffLevelBase,
|
||||
maxBuffLevelSkillArg: component.maxBuffLevelSkillArg,
|
||||
settingFlags: component.settingFlags,
|
||||
isDotMagic: true,
|
||||
isShowPowerLevel: component.isShowPowerLv,
|
||||
isDotMagicNoKill: component.isNoKill,
|
||||
isMaxMagicLevelTargetBound: component.isTargetBound,
|
||||
isFixDotPower: component.isFixDotPower,
|
||||
dotTimeSeconds: component.dotTimeSeconds,
|
||||
dotType: component.dotType,
|
||||
dotBase: component.dotBase,
|
||||
dotSkillLevelArg: component.dotSkillLevelArg,
|
||||
buffMaxLevel: component.buffMaxLevel,
|
||||
isIgnoreMagicAbsorb: component.ignoreMagicAbsorb,
|
||||
rawSettingExFlags: component.rawSettingExFlags,
|
||||
};
|
||||
}
|
||||
|
||||
const enemyDamageBindings = [];
|
||||
for (const npc of nativeCombatData.npcs ?? []) {
|
||||
const npcId = Number(npc.npcId);
|
||||
const level = Number(npc.level);
|
||||
const maxCasterLevel = NATIVE_NPC_GROWTH_LEVEL_MAX;
|
||||
if (!Number.isSafeInteger(npcId) || npcId <= 0 || !Number.isSafeInteger(maxCasterLevel) || maxCasterLevel < 1) continue;
|
||||
const ranks = [];
|
||||
for (const spell of npc.spellBindings ?? []) {
|
||||
const sourceSkillId = Number(spell.sourceSkillId);
|
||||
const collect = spell.magicCollect ?? {};
|
||||
const maxMagicLevel = Number(spell.magicLevel);
|
||||
const componentIds = (spell.components ?? []).map(importedEnemyDotComponent).filter(Boolean);
|
||||
if (!Number.isSafeInteger(sourceSkillId) || !Number.isSafeInteger(maxMagicLevel)
|
||||
|| !Number.isFinite(Number(collect.spellTimeSeconds)) || componentIds.length === 0) continue;
|
||||
ranks.push({
|
||||
rank: null,
|
||||
unlockLevel: level,
|
||||
sourceSkillId,
|
||||
maxCasterLevel,
|
||||
maxMagicLevel,
|
||||
configuredCollectMagicLevel: maxMagicLevel,
|
||||
rawSpellTimeSeconds: Number(collect.spellTimeSeconds),
|
||||
rawCollectMode: Number(collect.rawCollectMode),
|
||||
rawMagicCollectSettingExFlags: Number(collect.rawSettingExFlags),
|
||||
isFixDmgEffect: collect.isFixDmgEffect === true,
|
||||
componentIds,
|
||||
});
|
||||
}
|
||||
if (!ranks.length) continue;
|
||||
enemyDamageBindings.push({
|
||||
abilityId: `rom-npc-${npcId}`,
|
||||
classId: "rom-npc",
|
||||
npcIds: [npcId],
|
||||
maxCasterLevel,
|
||||
group: "enemy-spell",
|
||||
unlockLevel: level,
|
||||
semanticKind: "dot",
|
||||
sourceSkillIds: ranks.map((rank) => rank.sourceSkillId),
|
||||
ranks,
|
||||
});
|
||||
}
|
||||
|
||||
const uniqueComponents = new Map();
|
||||
for (const ability of [...activeDamageBindings, ...enemyDamageBindings]) {
|
||||
for (const rank of ability.ranks) {
|
||||
for (const component of rank.componentIds) uniqueComponents.set(component.componentId, component);
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
source: {
|
||||
magicCollectDatabase: "Resource/data/magiccollectobject.db",
|
||||
magicCollectSha256: sha256(magicCollect),
|
||||
magicObjectDatabase: "Resource/data/magicobject.db",
|
||||
magicObjectSha256: sha256(magicObject),
|
||||
magicProcessSource: "SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
|
||||
magicProcessSha256: sha256(magicProcess),
|
||||
objectStructSource: "SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h",
|
||||
objectStructSha256: sha256(objectStruct),
|
||||
periodicRankSource: "SourceCodeVS2019/Libraries/Union/RoleData/RD_Normal.cxx",
|
||||
periodicRankSourceSha256: sha256(periodicRankSource),
|
||||
periodicTickSource: "SourceCodeVS2019/Libraries/Union/RoleData/RD_ValueCal.cxx",
|
||||
periodicTickSourceSha256: sha256(periodicTickSource),
|
||||
playerCatalog: "src/game/romPlayerCatalog.generated.json",
|
||||
playerCatalogSha256: sha256(catalogBuffer),
|
||||
nativeStatRules: "src/game/generated/romNativeStatRules.generated.json",
|
||||
nativeStatRulesSha256: sha256(nativeStatRulesBuffer),
|
||||
nativeCombatData: "src/game/generated/romNativeCombatData.generated.json",
|
||||
nativeCombatDataSha256: sha256(nativeCombatDataBuffer),
|
||||
npcMagicCollectSha256: nativeCombatData.source?.magicCollectSha256,
|
||||
npcMagicObjectSha256: nativeCombatData.source?.magicObjectSha256,
|
||||
nativeNpcGrowthLevelMax: NATIVE_NPC_GROWTH_LEVEL_MAX,
|
||||
},
|
||||
scope: {
|
||||
activePairs: ACTIVE_PAIRS,
|
||||
semanticKind: "dot",
|
||||
sourceEffectFilter: "enabled negative-Base DotMagic on HP or HP_Per; resource-drain DotType rows are excluded",
|
||||
abilityCount: activeDamageBindings.length,
|
||||
sourceRankCount: activeDamageBindings.reduce((sum, ability) => sum + ability.ranks.length, 0),
|
||||
uniqueComponentCount: uniqueComponents.size,
|
||||
enemyNpcCount: enemyDamageBindings.length,
|
||||
enemyCasterLevelMax: NATIVE_NPC_GROWTH_LEVEL_MAX,
|
||||
enemySourceRankCount: enemyDamageBindings.reduce((sum, ability) => sum + ability.ranks.length, 0),
|
||||
enemyUniqueComponentCount: new Set(enemyDamageBindings.flatMap((ability) => ability.ranks.flatMap((rank) => rank.componentIds.map((component) => component.componentId)))).size,
|
||||
},
|
||||
recordLayout: {
|
||||
magicCollect: { offset: DATA_START, recordSize: MAGIC_COLLECT_RECORD_SIZE, type: 21 },
|
||||
magicObject: { offset: DATA_START, recordSize: MAGIC_OBJECT_RECORD_SIZE, type: 3 },
|
||||
magicCollectSpellTimeOffset: 0x114,
|
||||
magicCollectModeOffset: 0x368,
|
||||
magicCollectModeFixDamageBit: 16,
|
||||
magicObjectMaxBuffLevelBaseOffset: 0xcc,
|
||||
magicObjectMaxBuffLevelSkillArgOffset: 0xd0,
|
||||
magicObjectBuffMaxLevelOffset: 0x3b4,
|
||||
magicObjectEffectTimeOffset: 0xbc,
|
||||
magicObjectLevelDeltaEffectTimeOffset: 0xc0,
|
||||
magicObjectEffectTimeSkillLevelArgOffset: 0xc4,
|
||||
magicObjectSettingFlagsOffset: 0xd8,
|
||||
magicObjectDotMagicEnabledBit: COMPONENT_FILTER.dotMagicFlagBit,
|
||||
magicObjectShowPowerLevelBit: COMPONENT_FILTER.showPowerLevelBit,
|
||||
magicObjectDotMagicNoKillBit: COMPONENT_FILTER.dotMagicNoKillBit,
|
||||
magicObjectMaxMagicLevelTargetBoundBit: COMPONENT_FILTER.maxMagicLevelTargetBoundBit,
|
||||
magicObjectFixDotPowerBit: COMPONENT_FILTER.fixDotPowerBit,
|
||||
magicObjectDotTimeOffset: 0x184,
|
||||
magicObjectDotTypeOffset: 0x188,
|
||||
magicObjectDotBaseOffset: 0x18c,
|
||||
magicObjectDotSkillLevelArgOffset: 0x190,
|
||||
magicObjectSettingExFlagsOffset: MAGIC_OBJECT_RECORD_SIZE - 4,
|
||||
magicObjectIgnoreMagicAbsorbBit: COMPONENT_FILTER.ignoreMagicAbsorbBit,
|
||||
},
|
||||
formula: {
|
||||
sourceOrdering: "MagicProcess computes the target-adjusted buff duration, then adds CalAddDotMagicLv to MagicProc.MagicLv; ShowPowerLv replaces that result with zero before AssistMagic stores Buff.Power. AssistMagic applies MaxBuffLv_Base/Arg, optional target-level and BuffMaxLv caps before storing the integer power.",
|
||||
sourceTick: "BuffProc emits int(-Dot.Base * (100 + DotSkillLVArg * frozen Buff.Power) / 100) once when remaining Buff.Time is divisible by Dot.Time. HP_Per then multiplies by the target's current MaxHP / 100 and truncates again.",
|
||||
rankCorrection: "CalAddDotMagicLv returns zero for FixDotPower, non-HP/MP DotType, or DotSkillLVArg zero. For negative HP DotMagic it snapshots Magic/Physics source power and target-specific source defense terms before deriving the stored Buff.Power.",
|
||||
fixDotMagicalDamageCoefficient: nativeStatRules.playerFix.dotMagicalDamageCoefficient,
|
||||
formulaFiles: [
|
||||
"SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
|
||||
"SourceCodeVS2019/Libraries/Union/RoleData/RD_Normal.cxx",
|
||||
"SourceCodeVS2019/Libraries/Union/RoleData/RD_ValueCal.cxx",
|
||||
"SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h",
|
||||
],
|
||||
},
|
||||
activeDamageBindings,
|
||||
enemyDamageBindings,
|
||||
};
|
||||
|
||||
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
||||
console.log(`Wrote ${activeDamageBindings.length} active-five abilities and ${enemyDamageBindings.length} imported NPC bindings with ${output.scope.sourceRankCount + output.scope.enemySourceRankCount} source ranks and ${uniqueComponents.size} unique negative HP DoT components to ${path.relative(projectRoot, outputPath)}.`);
|
||||
@@ -35,6 +35,18 @@ const MAGIC_COLLECT_RECORD_SIZE = 1032;
|
||||
const MAGIC_OBJECT_RECORD_SIZE = 992;
|
||||
const MAGIC_COLLECT_TYPE = 21;
|
||||
const MAGIC_OBJECT_TYPE = 3;
|
||||
const PHYSICAL_CALCULATION_TYPES = new Set([0, 3, 9]);
|
||||
const MAGICAL_CALCULATION_TYPES = new Set([1, 10]);
|
||||
const SOURCE_CHANNELS = ["earth", "water", "fire", "wind", "light", "darkness"];
|
||||
const RUNTIME_SCHOOLS_BY_SOURCE_CHANNEL = {
|
||||
earth: "nature",
|
||||
water: "frost",
|
||||
fire: "fire",
|
||||
wind: "nature",
|
||||
light: "holy",
|
||||
darkness: "shadow",
|
||||
physical: "physical",
|
||||
};
|
||||
|
||||
function sha256(buffer) {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
@@ -128,6 +140,7 @@ function magicComponent(id) {
|
||||
magicFunc,
|
||||
magicType,
|
||||
effectType,
|
||||
calculationType: int32(magicObject, offset + 0x2d4),
|
||||
attackType,
|
||||
damagePower,
|
||||
fixedValue,
|
||||
@@ -141,10 +154,27 @@ function magicComponent(id) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function sourceDamageClassForCalculationType(calculationType) {
|
||||
if (PHYSICAL_CALCULATION_TYPES.has(calculationType)) return "physical";
|
||||
if (MAGICAL_CALCULATION_TYPES.has(calculationType)) return "magical";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sourceChannelForMagicType(magicType) {
|
||||
if (magicType === -1) return "physical";
|
||||
return SOURCE_CHANNELS[magicType];
|
||||
}
|
||||
|
||||
function schoolFor(component) {
|
||||
if (component.effectType === 1) return "physical";
|
||||
return ["nature", "frost", "fire", "nature", "holy", "shadow"][component.magicType]
|
||||
?? "arcane";
|
||||
const sourceChannel = sourceChannelForMagicType(component.magicType);
|
||||
return sourceChannel ? RUNTIME_SCHOOLS_BY_SOURCE_CHANNEL[sourceChannel] : undefined;
|
||||
}
|
||||
|
||||
function attackKindForCalculationType(calculationType) {
|
||||
if (calculationType === 0 || calculationType === 9) return "melee";
|
||||
if (calculationType === 3) return "ranged";
|
||||
if (calculationType === 1 || calculationType === 10) return "spell";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formulaFor(spellId, component) {
|
||||
@@ -159,7 +189,8 @@ function formulaFor(spellId, component) {
|
||||
spellId,
|
||||
componentId: component.id,
|
||||
spellLevel: 0,
|
||||
calculationPath: component.effectType === 1 ? "physical" : "magic",
|
||||
calculationPath: sourceDamageClassForCalculationType(component.calculationType) === "physical"
|
||||
? "physical" : "magic",
|
||||
powerKind,
|
||||
damagePower: component.damagePower,
|
||||
fixedValue: component.fixedValue,
|
||||
@@ -257,12 +288,23 @@ for (const [spellId, assignmentMap] of [...assignmentsBySpell].sort((left, right
|
||||
const effectRange = int32(magicCollect, offset + 0xc0);
|
||||
const rangeType = int32(magicCollect, offset + 0xc4);
|
||||
const sourceCooldownSeconds = int32(magicCollect, offset + 0x108);
|
||||
const primaryDamage = damageComponents[0];
|
||||
const runtimeDamageComponents = damageComponents.filter((component) => (
|
||||
sourceDamageClassForCalculationType(component.calculationType)
|
||||
&& attackKindForCalculationType(component.calculationType)
|
||||
&& schoolFor(component)
|
||||
));
|
||||
const primaryDamage = runtimeDamageComponents[0];
|
||||
const unsupportedReasons = [];
|
||||
if (!components.length) unsupportedReasons.push("No referenced MagicObjectDB components were found.");
|
||||
if (!damageComponents.length) {
|
||||
unsupportedReasons.push("No component is positively classified as direct HP damage.");
|
||||
}
|
||||
if (damageComponents.length !== runtimeDamageComponents.length) {
|
||||
unsupportedReasons.push(
|
||||
(damageComponents.length - runtimeDamageComponents.length)
|
||||
+ " direct-damage component(s) have an unsupported Attack.CalType or MagicType.",
|
||||
);
|
||||
}
|
||||
if (components.length !== damageComponents.length) {
|
||||
unsupportedReasons.push(
|
||||
(components.length - damageComponents.length)
|
||||
@@ -294,7 +336,9 @@ for (const [spellId, assignmentMap] of [...assignmentsBySpell].sort((left, right
|
||||
delivery: range > 5 ? "projectile" : "melee",
|
||||
target: "primary",
|
||||
school: schoolFor(primaryDamage),
|
||||
animation: range > 5 || primaryDamage.effectType === 0 ? "cast" : "attack",
|
||||
runewakerEffectType: sourceDamageClassForCalculationType(primaryDamage.calculationType) === "magical" ? 0 : 1,
|
||||
attackKind: attackKindForCalculationType(primaryDamage.calculationType),
|
||||
animation: range > 5 || attackKindForCalculationType(primaryDamage.calculationType) === "spell" ? "cast" : "attack",
|
||||
range,
|
||||
cooldownMs: Math.max(2500, Math.min(20000, sourceCooldownSeconds > 0
|
||||
? sourceCooldownSeconds * 1000
|
||||
|
||||
@@ -149,6 +149,9 @@ function damageMechanic(entityId, spell) {
|
||||
delivery: spatial.delivery,
|
||||
target: spatial.target,
|
||||
school: runtime.school,
|
||||
damageOrigin: "runewaker",
|
||||
attackKind: runtime.attackKind,
|
||||
runewakerEffectType: runtime.runewakerEffectType,
|
||||
animation: runtime.animation,
|
||||
castTimeMs,
|
||||
range: Math.max(runtime.range, spatial.radius ?? 0),
|
||||
|
||||
@@ -5,9 +5,9 @@ head 0 0
|
||||
hair 0 0
|
||||
helmet 0 0
|
||||
shoulder 0 0
|
||||
torso 0 0
|
||||
hand 0 0
|
||||
torso cloth010-001 0 0
|
||||
hand cloth010-001 0 0
|
||||
belt 0 0
|
||||
leg 0 0
|
||||
foot 0 0
|
||||
leg cloth010-001 0 0
|
||||
foot cloth010-001 0 0
|
||||
back 0 0
|
||||
|
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"dungeonId": "runewaker-starter-weapons",
|
||||
"zoneId": 1,
|
||||
"coordinateSystem": {
|
||||
"metersPerSourceUnit": 0.1,
|
||||
"directionUnitsPerTurn": 256,
|
||||
"maximumProjectionDistance": 2
|
||||
},
|
||||
"files": {
|
||||
"snapshot": "scripts/runewaker-pipeline/snapshots/runewaker-starter-weapons-unused.json",
|
||||
"generatedSource": "src/game/generated/runewakerStarterWeapons.unused.ts",
|
||||
"report": "scripts/runewaker-pipeline/snapshots/runewaker-starter-weapons-import-report.json",
|
||||
"environmentMetadata": "scripts/runewaker-pipeline/snapshots/runewaker-starter-weapons-environment-unused.json",
|
||||
"navigation": "scripts/runewaker-pipeline/snapshots/runewaker-starter-weapons-navigation-unused.glb",
|
||||
"actorManifest": "public/assets/equipment/runewaker-v1/import-manifest.json",
|
||||
"actorOutput": "public/assets/equipment/runewaker-v1"
|
||||
},
|
||||
"source": {
|
||||
"rootEnvironmentVariable": "RUNEWAKER_ROOT",
|
||||
"relativeDefault": "../Runewaker",
|
||||
"resourceRoot": "Resource",
|
||||
"catalogs": []
|
||||
},
|
||||
"actors": [
|
||||
{
|
||||
"id": "starter-sword",
|
||||
"sourceModel": "model/item/weapon/blade/act_blade01_000_001.ros",
|
||||
"runtimeRotationY": 0
|
||||
},
|
||||
{
|
||||
"id": "starter-mace",
|
||||
"sourceModel": "model/item/weapon/hammer/act_hammer01_000_001.ros",
|
||||
"runtimeRotationY": 0
|
||||
},
|
||||
{
|
||||
"id": "starter-shield",
|
||||
"sourceModel": "model/item/weapon/shield/act_shield01_000_001.ros",
|
||||
"runtimeRotationY": 0
|
||||
},
|
||||
{
|
||||
"id": "starter-staff",
|
||||
"sourceModel": "model/item/weapon/staff/act_staff00_000_001.ros",
|
||||
"runtimeRotationY": 0
|
||||
},
|
||||
{
|
||||
"id": "starter-bow",
|
||||
"sourceModel": "model/item/weapon/bow/act_bow03_010_001.ros",
|
||||
"runtimeRotationY": 0
|
||||
},
|
||||
{
|
||||
"id": "starter-dagger",
|
||||
"sourceModel": "model/item/weapon/dagger/act_dagger01_010_005.ros",
|
||||
"runtimeRotationY": 0
|
||||
}
|
||||
],
|
||||
"templates": [],
|
||||
"deferred": []
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user