Update Healer Man server

This commit is contained in:
phenom committed 2026-10-04 13:13:35 -04:00
1 parent 39458d5de6
commit 6cc1b9217f
203 files changed
+133929 -31271

No files matched your search

+79
View File
@@ -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`);
}
+152
View File
@@ -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
+893
View File
@@ -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; });
+116
View File
@@ -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; });
+172
View File
@@ -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; });
+38 -3
View File
@@ -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);
+793
View File
@@ -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
1 # HealerMan RuneWaker paperdoll starter appearance
5 hair
6 helmet
7 shoulder
8 torso
9 hand
10 belt
11 leg
12 foot
13 back
@@ -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