forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
6114a2295f
commit
69809f7228
593 files changed
+903643
-40774
No files matched your search
@@ -0,0 +1,832 @@
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
FULL_STOCKADE_DUNGEON_ID,
|
||||
FULL_STOCKADE_DUNGEON_TITLE,
|
||||
FULL_STOCKADE_BOSS,
|
||||
} = require('./playtest-defaults.cjs');
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
const contentSourceDirectory = path.join(projectRoot, 'content');
|
||||
const contentManifestPath = path.join(contentSourceDirectory, 'manifest.json');
|
||||
const sourceHarnessPath = path.join(projectRoot, 'scripts', 'playtest-campaign-boss-pass.cjs');
|
||||
const sourceHarness = fs.readFileSync(sourceHarnessPath, 'utf8');
|
||||
const prepareOnly = process.argv.includes('--prepare-only');
|
||||
const frozenBuildAuthorized = process.argv.includes('--frozen-build-authorized');
|
||||
const runId = new Date().toISOString().replace(/[-:.]/g, '').replace('T', '-').replace('Z', '');
|
||||
const outputDirectory = path.join(projectRoot, 'playtest-artifacts', 'stockade-stances-blocking', runId);
|
||||
const childOutputDirectory = path.join(outputDirectory, 'campaign');
|
||||
const runtimeHarnessPath = path.join(__dirname, `.playtest-stockade-stances-blocking-${process.pid}.cjs`);
|
||||
const requestedBuildDirectory = process.env.HEALER_MAN_STOCKADE_DIST?.trim()
|
||||
? path.resolve(process.env.HEALER_MAN_STOCKADE_DIST.trim()) : null;
|
||||
const buildDirectory = requestedBuildDirectory ?? path.join(projectRoot, 'dist');
|
||||
const expectedBuildSha256Argument = process.argv.find((value) => value.startsWith('--expected-build-sha256='))
|
||||
?.slice('--expected-build-sha256='.length);
|
||||
const expectedBuildSha256 = expectedBuildSha256Argument
|
||||
?? process.env.HEALER_MAN_STOCKADE_DIST_SHA256?.trim() ?? null;
|
||||
|
||||
function sha256(bytes) {
|
||||
return crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function replaceExactlyOnce(source, marker, replacement, label) {
|
||||
const count = source.split(marker).length - 1;
|
||||
if (count !== 1) throw new Error(`${label} must occur exactly once in the source harness; found ${count}.`);
|
||||
return source.replace(marker, replacement);
|
||||
}
|
||||
|
||||
function replaceFirstOccurrence(source, marker, replacement, label) {
|
||||
if (!source.includes(marker)) throw new Error(`${label} marker was not found in the source harness.`);
|
||||
return source.replace(marker, replacement);
|
||||
}
|
||||
|
||||
function collectBuildManifest() {
|
||||
if (!fs.existsSync(buildDirectory) || !fs.statSync(buildDirectory).isDirectory()) {
|
||||
throw new Error(`Frozen build directory is missing or is not a directory: ${buildDirectory}`);
|
||||
}
|
||||
const files = [];
|
||||
function visit(directory, relativeDirectory = '') {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const relativePath = path.posix.join(relativeDirectory, entry.name);
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) visit(absolutePath, relativePath);
|
||||
else if (entry.isFile()) {
|
||||
const bytes = fs.readFileSync(absolutePath);
|
||||
files.push({ path: relativePath, bytes: bytes.length, sha256: sha256(bytes) });
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(buildDirectory);
|
||||
const canonicalManifest = files.map((file) => `${file.path}\t${file.bytes}\t${file.sha256}\n`).join('');
|
||||
const bundleModuleReport = files.find((file) => file.path === 'bundle-module-report.json');
|
||||
return {
|
||||
capturedAt: new Date().toISOString(),
|
||||
directory: buildDirectory,
|
||||
fileCount: files.length,
|
||||
aggregateSha256: sha256(canonicalManifest),
|
||||
bundleModuleReportSha256: bundleModuleReport?.sha256 ?? null,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function collectContentManifestSnapshot() {
|
||||
const bytes = fs.readFileSync(contentManifestPath);
|
||||
const manifest = JSON.parse(bytes.toString('utf8'));
|
||||
return {
|
||||
capturedAt: new Date().toISOString(),
|
||||
directory: contentSourceDirectory,
|
||||
manifestPath: contentManifestPath,
|
||||
manifestBytes: bytes.length,
|
||||
manifestSha256: sha256(bytes),
|
||||
manifestVersion: manifest.version ?? null,
|
||||
schemaVersion: manifest.schemaVersion ?? null,
|
||||
packIds: Array.isArray(manifest.packs) ? manifest.packs.map((pack) => pack.id).sort() : [],
|
||||
fileCount: manifest.files && typeof manifest.files === 'object' ? Object.keys(manifest.files).length : null,
|
||||
};
|
||||
}
|
||||
|
||||
function collectLoadedContentObjectEvidence(responses) {
|
||||
const objectResponses = responses.filter((response) => response?.pathname?.startsWith('/content/objects/'));
|
||||
const responseCountsByPath = objectResponses.reduce((counts, response) => {
|
||||
counts.set(response.pathname, (counts.get(response.pathname) ?? 0) + 1);
|
||||
return counts;
|
||||
}, new Map());
|
||||
const uniqueObjects = [...new Map(objectResponses.map((response) => [response.pathname, {
|
||||
...response,
|
||||
responseCount: responseCountsByPath.get(response.pathname),
|
||||
}])).values()];
|
||||
return uniqueObjects.map((response) => {
|
||||
const relativeObjectPath = decodeURIComponent(response.pathname.slice('/content/'.length));
|
||||
const absoluteObjectPath = path.resolve(contentSourceDirectory, relativeObjectPath);
|
||||
const relativeCheck = path.relative(contentSourceDirectory, absoluteObjectPath);
|
||||
if (relativeCheck.startsWith('..') || path.isAbsolute(relativeCheck)) {
|
||||
return { ...response, localObjectPath: null, localBytes: null, localSha256: null,
|
||||
objectIntegrity: 'unsafe-path-refused' };
|
||||
}
|
||||
if (!fs.existsSync(absoluteObjectPath) || !fs.statSync(absoluteObjectPath).isFile()) {
|
||||
return { ...response, localObjectPath: path.relative(projectRoot, absoluteObjectPath),
|
||||
localBytes: null, localSha256: null, objectIntegrity: 'not-found-on-content-source' };
|
||||
}
|
||||
const bytes = fs.readFileSync(absoluteObjectPath);
|
||||
const actualSha256 = sha256(bytes);
|
||||
const urlObjectSha256 = /^([a-f0-9]{64})(?:\.|$)/i.exec(path.basename(absoluteObjectPath))?.[1]?.toLowerCase() ?? null;
|
||||
return {
|
||||
...response,
|
||||
localObjectPath: path.relative(projectRoot, absoluteObjectPath),
|
||||
localBytes: bytes.length,
|
||||
localSha256: actualSha256,
|
||||
urlObjectSha256,
|
||||
objectIntegrity: response.status === 200 && urlObjectSha256
|
||||
? actualSha256 === urlObjectSha256 ? 'served-file-matches-hash-address' : 'served-file-hash-mismatch'
|
||||
: response.status === 200 ? 'served-without-hash-address' : 'http-not-successful',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createInstrumentedHarness() {
|
||||
if (FULL_STOCKADE_DUNGEON_ID !== 'stormwind-stockade'
|
||||
|| FULL_STOCKADE_DUNGEON_TITLE !== 'Stormwind Stockade') {
|
||||
throw new Error(`Full Stockade fixture changed unexpectedly: ${FULL_STOCKADE_DUNGEON_ID} / ${FULL_STOCKADE_DUNGEON_TITLE}`);
|
||||
}
|
||||
|
||||
const mobMarker = ' schoolLockedUntil: mob.schoolLockedUntil ?? {},';
|
||||
const outputMarker = "const artifactRoot = path.resolve('playtest-artifacts/campaign-boss-pass');";
|
||||
const runtimeRelativeOutput = path.relative(projectRoot, childOutputDirectory).replace(/\\/g, '/');
|
||||
|
||||
let transformed = sourceHarness;
|
||||
transformed = replaceExactlyOnce(transformed, outputMarker,
|
||||
`const artifactRoot = path.resolve(${JSON.stringify(runtimeRelativeOutput)});`, 'Campaign output directory');
|
||||
transformed = replaceExactlyOnce(transformed, mobMarker, ` attacks: (mob.attacks ?? []).map((attack) => ({
|
||||
id: attack.id, name: attack.name, spellId: attack.spellId ?? null,
|
||||
school: attack.school ?? null, castTimeMs: attack.castTimeMs ?? 0,
|
||||
cooldownMs: attack.cooldownMs ?? 0, interruptible: attack.interruptible !== false,
|
||||
})),
|
||||
auras: mob.auras ?? [],
|
||||
combatAuraTelemetry: (combatState.auras ?? []).filter((aura) => aura.targetId === mob.id).map((aura) => ({
|
||||
instanceId: aura.instanceId ?? null,
|
||||
definitionId: aura.definition?.id ?? null,
|
||||
definitionName: aura.definition?.name ?? null,
|
||||
sourceId: aura.sourceId ?? null,
|
||||
targetId: aura.targetId ?? null,
|
||||
appliedAt: aura.appliedAt ?? null,
|
||||
expiresAt: aura.expiresAt ?? null,
|
||||
stacks: aura.stacks ?? null,
|
||||
definition: aura.definition ?? null,
|
||||
})),
|
||||
schoolLockedUntil: mob.schoolLockedUntil ?? {},`, 'Mob telemetry projection');
|
||||
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
" combatAuras: (combatState.auras ?? []).filter((aura) => aura.definition?.id === 'rom-priest-vow-of-silence')\n .map((aura) => ({ instanceId: aura.instanceId, definitionId: aura.definition.id,\n sourceId: aura.sourceId, targetId: aura.targetId, appliedAt: aura.appliedAt,\n expiresAt: aura.expiresAt })),",
|
||||
" combatAuras: (combatState.auras ?? []).filter((aura) => aura.definition?.id === 'rom-priest-vow-of-silence')\n .map((aura) => ({ instanceId: aura.instanceId, definitionId: aura.definition.id,\n sourceId: aura.sourceId, targetId: aura.targetId, appliedAt: aura.appliedAt,\n expiresAt: aura.expiresAt })),\n stanceBlockingAuraTelemetry: (combatState.auras ?? []).filter((aura) => /^enemy-source-spell-(?:7164|7165|3419)$/.test(aura.definition?.id ?? '')).map((aura) => ({\n instanceId: aura.instanceId ?? null, definitionId: aura.definition?.id ?? null,\n definitionName: aura.definition?.name ?? null, sourceId: aura.sourceId ?? null,\n targetId: aura.targetId ?? null, appliedAt: aura.appliedAt ?? null,\n expiresAt: aura.expiresAt ?? null, stacks: aura.stacks ?? null,\n })),",
|
||||
'stance and Improved Blocking aura telemetry');
|
||||
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
" partySelection: {\n selectedMemberId: partyState.selectedMemberId ?? null,\n selfSelected: Boolean(partyState.selfSelected),\n },",
|
||||
" partySelection: {\n selectedMemberId: partyState.selectedMemberId ?? null,\n selfSelected: Boolean(partyState.selfSelected),\n },\n partyCommand: partyState.command ?? null,",
|
||||
'read-only party command sampling');
|
||||
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
" classId: combatState.classId,\n secondaryClassId: combatState.secondaryClassId,\n level: combatState.level,",
|
||||
" classId: combatState.classId,\n secondaryClassId: combatState.secondaryClassId,\n specializationId: combatState.specializationId ?? null,\n level: combatState.level,",
|
||||
'player specialization telemetry');
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
" activeCast: combatState.activeCast?.abilityId ?? null,",
|
||||
" activeCast: combatState.activeCast?.abilityId ?? null,\n lastCombatResult: combatState.lastCombatResult ? { ...combatState.lastCombatResult } : null,",
|
||||
'read-only last combat result telemetry');
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
" classId: member.classId,\n secondaryClassId: member.secondaryClassId,\n role: member.role,",
|
||||
" classId: member.classId,\n secondaryClassId: member.secondaryClassId,\n specializationId: member.specializationId ?? null,\n role: member.role,",
|
||||
'party specialization telemetry');
|
||||
|
||||
const distPathReplacements = [
|
||||
["fs.readFileSync('dist/bundle-module-report.json', 'utf8')",
|
||||
"fs.readFileSync(path.join(process.env.HEALER_MAN_STOCKADE_DIST, 'bundle-module-report.json'), 'utf8')",
|
||||
'bundle module report path'],
|
||||
["path.join('dist/assets', dungeonRunStoreAssetFile)",
|
||||
"path.join(process.env.HEALER_MAN_STOCKADE_DIST, 'assets', dungeonRunStoreAssetFile)",
|
||||
'dungeon run module verification path'],
|
||||
["fs.readdirSync('dist/assets')",
|
||||
"fs.readdirSync(path.join(process.env.HEALER_MAN_STOCKADE_DIST, 'assets'))",
|
||||
'module asset enumeration path'],
|
||||
["const runtime = createGameServer({ dataDir: dataDirectory, enableShelvedModes: false });",
|
||||
"const runtime = createGameServer({ dataDir: dataDirectory, staticDir: process.env.HEALER_MAN_STOCKADE_DIST, contentDir: path.resolve('content'), enableShelvedModes: false });\n if (path.resolve(runtime.staticDir) !== path.resolve(process.env.HEALER_MAN_STOCKADE_DIST)) {\n throw new Error(`Playtest server resolved an unexpected static directory: ${runtime.staticDir}`);\n }\n if (path.resolve(runtime.contentDir) !== path.resolve('content')) {\n throw new Error(`Playtest server resolved an unexpected read-only content source: ${runtime.contentDir}`);\n }",
|
||||
'static server document root'],
|
||||
[" const pageErrors = [];\n const consoleErrors = [];",
|
||||
" const pageErrors = [];\n const consoleErrors = [];\n const contentSourceResponses = [];",
|
||||
'content source response telemetry'],
|
||||
[" page.on('pageerror', (error) => pageErrors.push(error.message));",
|
||||
" page.on('pageerror', (error) => pageErrors.push(error.message));\n page.on('response', (response) => {\n try {\n const url = new URL(response.url());\n if (url.pathname !== '/content/manifest.json' && !url.pathname.startsWith('/content/objects/')) return;\n const headers = response.headers();\n contentSourceResponses.push({\n observedAt: new Date().toISOString(),\n method: response.request().method(),\n url: response.url(),\n pathname: url.pathname,\n status: response.status(),\n contentType: headers['content-type'] ?? null,\n contentLength: headers['content-length'] ?? null,\n contentEncoding: headers['content-encoding'] ?? null,\n cacheControl: headers['cache-control'] ?? null,\n });\n } catch (error) {\n contentSourceResponses.push({ observedAt: new Date().toISOString(), instrumentationError: String(error) });\n }\n });",
|
||||
'content source response listener'],
|
||||
["Expected built game assets were not found in dist/assets: ${moduleFiles.join(', ')}",
|
||||
"Expected built game assets were not found in the frozen Stockade distribution: ${moduleFiles.join(', ')}",
|
||||
'asset failure message'],
|
||||
];
|
||||
for (const [marker, replacement, label] of distPathReplacements) {
|
||||
transformed = replaceExactlyOnce(transformed, marker, replacement, label);
|
||||
}
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
' const routeResult = await runRouteSegment(page, route, entry, profile);',
|
||||
' const routeResult = await runRouteSegment(page, route, entry, profile);\n routeResult.contentSourceResponses = contentSourceResponses.slice();',
|
||||
'route content source evidence');
|
||||
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
" await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();\n await page.locator('.class-option').filter({ hasText: /^Priest/ }).click();",
|
||||
" await page.locator('.creator-category-tabs button').filter({ hasText: 'WoW' }).click();\n await page.locator('.class-option').filter({ hasText: /^Priest/ }).click();\n await page.locator('.creator-specializations button').filter({ hasText: 'Discipline' }).click();",
|
||||
'normal WoW Discipline Priest character creation');
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
" if (!character || character.classId !== 'rom-priest') throw new Error('UI-created RoM Priest profile is missing.');\n shell.setState({ activeCharacter: character });\n shell.getState().saveActiveProgression(30, 0, character.talentRanks);\n shell.setState({ activeCharacter: null });\n if (!shell.getState().chooseRomSecondaryClass('rom-knight')) throw new Error('Priest/Knight pairing was not accepted.');\n const updated = shell.getState().characters.find((entry) => entry.id === character.id);\n return { id: updated.id, name: updated.name, classId: updated.classId,\n secondaryClassId: updated.secondaryClassId, level: updated.level, categoryId: updated.categoryId };",
|
||||
" if (!character || character.classId !== 'priest' || character.specializationId !== 'priest:discipline') {\n throw new Error('UI-created WoW Discipline Priest profile is missing.');\n }\n shell.setState({ activeCharacter: character });\n shell.getState().saveActiveProgression(30, 0, character.talentRanks);\n shell.setState({ activeCharacter: null });\n const updated = shell.getState().characters.find((entry) => entry.id === character.id);\n return { id: updated.id, name: updated.name, classId: updated.classId, specializationId: updated.specializationId,\n secondaryClassId: updated.secondaryClassId, level: updated.level, categoryId: updated.categoryId };",
|
||||
'profile creation and level setup');
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
" assert.equal(profile.classId, 'rom-priest');\n assert.equal(profile.secondaryClassId, 'rom-knight');",
|
||||
" assert.equal(profile.classId, 'priest');\n assert.equal(profile.specializationId, 'priest:discipline');\n assert.equal(profile.level, 30);",
|
||||
'Discipline profile assertions');
|
||||
transformed = transformed.replaceAll('roster-priest-knight-level-30.png', 'roster-discipline-priest-level-30.png')
|
||||
.replaceAll('roster-compact-priest-knight-level-30.png', 'roster-compact-discipline-priest-level-30.png');
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
" assert.equal(state.player.classId, 'rom-priest');\n assert.equal(state.player.secondaryClassId, 'rom-knight');\n assert(state.player.level >= minimumLevel,\n `${route.title} entry level remains at least the UI-created profile's level ${minimumLevel}`);\n assert.equal(state.party.length, 4, 'the UI-created Priest has four NPC companions');\n assert(state.party.every((member) => member.classId.startsWith('rom-') && member.statRuleset === 'rom'),\n 'all four companions use native RoM identities and rules');",
|
||||
" assert.equal(state.player.classId, 'priest');\n assert.equal(state.player.specializationId, 'priest:discipline');\n assert(state.player.level >= minimumLevel,\n `${route.title} entry level remains at least the UI-created profile's level ${minimumLevel}`);\n assert.equal(state.party.length, 4, 'the UI-created Discipline Priest has four NPC companions');\n const readySpecializationIds = new Set(['warrior:protection', 'priest:discipline',\n 'hunter:marksmanship', 'mage:fire', 'rogue:assassination']);\n assert(state.party.every((member) => readySpecializationIds.has(member.specializationId)),\n `all four companions use Ready WoW specializations: ${JSON.stringify(state.party.map((member) => member.specializationId))}`);",
|
||||
'Ready WoW companion assertions');
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
' activeCast: mob.activeCast, position: mob.position })) });',
|
||||
' activeCast: mob.activeCast, attacks: mob.attacks ?? [], auras: mob.auras ?? [],\n combatAuraTelemetry: mob.combatAuraTelemetry ?? [], abilityCastCounts: mob.abilityCastCounts ?? {},\n statuses: mob.statuses ?? [], position: mob.position })) });',
|
||||
'compact route sample enemy mechanics telemetry');
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
" activeCast: mob.activeCast, attacks: mob.attacks ?? [], auras: mob.auras ?? [],\n combatAuraTelemetry: mob.combatAuraTelemetry ?? [], abilityCastCounts: mob.abilityCastCounts ?? {},\n statuses: mob.statuses ?? [], position: mob.position })) });",
|
||||
" activeCast: mob.activeCast, attacks: mob.attacks ?? [], auras: mob.auras ?? [],\n combatAuraTelemetry: mob.combatAuraTelemetry ?? [], abilityCastCounts: mob.abilityCastCounts ?? {},\n statuses: mob.statuses ?? [], position: mob.position })),\n stanceBlockingAuraTelemetry: current.stanceBlockingAuraTelemetry ?? [] });",
|
||||
'compact route sample stance and blocking telemetry');
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
' movement: [], targeting: [], actions: [], samples: [], observedCombatEvents: [],',
|
||||
' movement: [], targeting: [], actions: [], samples: [], observedCombatEvents: [],\n observedStanceBlockingAuraObservations: [], observedBlockResultObservations: [],',
|
||||
'stance and block result observations');
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
' const seenEventKeys = initialEventKeys;\n const observedDeadMobs = new Map();',
|
||||
' const seenEventKeys = initialEventKeys;\n const observedStanceBlockingAurasById = new Map();\n const seenBlockResultKeys = new Set();\n const observedDeadMobs = new Map();',
|
||||
'read-only observation deduplication state');
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
' const collect = (snapshot) => {',
|
||||
` let lastProgressSnapshotAt = 0;
|
||||
let progressSnapshotCount = 0;
|
||||
const recordSnapshot = (snapshot) => {
|
||||
if (lastProgressSnapshotAt && snapshot.now - lastProgressSnapshotAt < 30_000) return;
|
||||
lastProgressSnapshotAt = snapshot.now;
|
||||
progressSnapshotCount += 1;
|
||||
const checkpointIndex = result.activeNavigationCheckpointIndex ?? null;
|
||||
const checkpoint = Number.isInteger(checkpointIndex)
|
||||
? result.navigationGuidance.checkpoints[checkpointIndex] ?? null : null;
|
||||
const killedBossIds = snapshot.dungeonRun?.killedBossIds ?? [];
|
||||
const killedBossIdSet = new Set(killedBossIds);
|
||||
const objectiveCount = result.objectives.length;
|
||||
const clearedObjectiveIds = result.objectives.filter((objective) => killedBossIdSet.has(objective.id))
|
||||
.map((objective) => objective.id);
|
||||
const mobs = snapshot.mobs.map((mob) => ({ id: mob.id, name: mob.name,
|
||||
health: mob.health, maxHealth: mob.maxHealth, dead: mob.dead, engaged: mob.engaged,
|
||||
boss: mob.boss ?? false, activeCast: mob.activeCast, position: mob.position }));
|
||||
const progress = {
|
||||
snapshotNumber: progressSnapshotCount,
|
||||
updatedAt: new Date(snapshot.now).toISOString(),
|
||||
elapsedMs: snapshot.now - result.segmentStartedAt,
|
||||
checkpoint: checkpoint ? { index: checkpointIndex, total: result.navigationGuidance.checkpoints.length,
|
||||
routePercent: Math.floor((checkpointIndex + 1) / result.navigationGuidance.checkpoints.length * 100),
|
||||
objectiveId: checkpoint.objectiveId ?? null, name: checkpoint.name ?? null,
|
||||
position: checkpoint.position ?? null,
|
||||
distance3d: distance3(snapshot.player.position, checkpoint.position) } : null,
|
||||
position: snapshot.player.position,
|
||||
player: { classId: snapshot.player.classId, specializationId: snapshot.player.specializationId,
|
||||
health: snapshot.player.health, maxHealth: snapshot.player.maxHealth,
|
||||
mana: snapshot.player.resource, maxMana: snapshot.player.maxResource,
|
||||
resourceType: snapshot.player.resourceType, resourceName: snapshot.player.resourceName,
|
||||
activeCast: snapshot.player.activeCast, selectedTargetId: snapshot.player.selectedTargetId,
|
||||
controlMechanic: snapshot.player.controlMechanic,
|
||||
actionBlockedByControl: snapshot.player.actionBlockedByControl },
|
||||
party: snapshot.party.map((member) => ({ id: member.id, name: member.name,
|
||||
specializationId: member.specializationId ?? null, role: member.role,
|
||||
health: member.health, maxHealth: member.maxHealth, status: member.status,
|
||||
activeCast: member.activeCast, position: member.position })),
|
||||
deaths: { player: result.playerDeathTransitions.length, party: partyCasualties.length,
|
||||
partyMembers: partyCasualties.map((casualty) => ({ id: casualty.id, name: casualty.name,
|
||||
observedAt: casualty.observedAt, recoveredAt: casualty.recoveredAt })) },
|
||||
partyCommand: { current: snapshot.partyCommand ?? null,
|
||||
recordedInputs: [result.partyCommand, result.partyCommandAfterOpeningTarget,
|
||||
...(result.partyCommands ?? [])].filter(Boolean) },
|
||||
mobs: { liveCount: mobs.filter((mob) => !mob.dead && mob.health > 0).length,
|
||||
deadCount: mobs.filter((mob) => mob.dead || mob.health <= 0).length,
|
||||
engagedCount: mobs.filter((mob) => mob.engaged && !mob.dead && mob.health > 0).length,
|
||||
entries: mobs },
|
||||
objectives: { count: objectiveCount, clearedCount: clearedObjectiveIds.length,
|
||||
clearedIds: clearedObjectiveIds, killedBossIds },
|
||||
recentEvents: (snapshot.events ?? []).slice(-20),
|
||||
recentMovement: result.movement.slice(-4).map((step) => ({ at: step.at,
|
||||
from: step.from, to: step.to, planarDisplacement: step.planarDisplacement,
|
||||
requestedDurationMs: step.requestedDurationMs, durationMs: step.durationMs })),
|
||||
lastRecovery: result.playerRecoveryActions.at(-1) ?? null,
|
||||
};
|
||||
const progressPath = process.env.HEALER_MAN_STOCKADE_PROGRESS_PATH
|
||||
?? path.join(outputDirectory, 'progress.json');
|
||||
fs.writeFileSync(progressPath, JSON.stringify(progress, null, 2));
|
||||
};
|
||||
const collect = (snapshot) => {`,
|
||||
'periodic read-only live progress snapshots');
|
||||
transformed = replaceFirstOccurrence(transformed,
|
||||
' result.observedCombatEvents.push(event);\n }\n };',
|
||||
` result.observedCombatEvents.push(event);\n }\n for (const aura of snapshot.stanceBlockingAuraTelemetry ?? []) {\n const auraKey = [aura.instanceId ?? '', aura.appliedAt ?? '', aura.definitionId ?? '',\n aura.sourceId ?? '', aura.targetId ?? ''].join('|');\n const existing = observedStanceBlockingAurasById.get(auraKey);\n if (existing) existing.lastObservedAt = snapshot.now;\n else {\n const observation = { firstObservedAt: snapshot.now, lastObservedAt: snapshot.now, ...aura };\n observedStanceBlockingAurasById.set(auraKey, observation);\n result.observedStanceBlockingAuraObservations.push(observation);\n }\n }\n const lastCombatResult = snapshot.player.lastCombatResult;\n if (lastCombatResult?.outcome === 'block') {\n const latestDamageEvent = [...snapshot.events].reverse().find((event) => event.kind === 'damage');\n const matchedLatestBlockEvent = latestDamageEvent?.outcome === 'block'\n && Number(latestDamageEvent.rawAmount) === Number(lastCombatResult.rawDamage)\n ? latestDamageEvent : null;\n const resultKey = matchedLatestBlockEvent?.id != null ? 'event:' + matchedLatestBlockEvent.id\n : 'unmatched:' + JSON.stringify(lastCombatResult);\n if (!seenBlockResultKeys.has(resultKey)) {\n seenBlockResultKeys.add(resultKey);\n result.observedBlockResultObservations.push({ observedAt: snapshot.now,\n correlation: matchedLatestBlockEvent ? 'latest-damage-event-outcome-and-raw-amount-match' : 'no-matching-latest-damage-event-sampled',\n event: matchedLatestBlockEvent, combatResult: { ...lastCombatResult } });\n }\n }\n };`,
|
||||
'read-only block result collection');
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
'\n };\n const deadline = Date.now() + maxCampaignDurationMs;',
|
||||
'\n recordSnapshot(snapshot);\n };\n const deadline = Date.now() + maxCampaignDurationMs;',
|
||||
'write periodic route observations');
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
' const firstTarget = await selectHostileWithTab(page, result, 8);',
|
||||
' collect(entryState);\n const firstTarget = await selectHostileWithTab(page, result, 8);',
|
||||
'entry aura and combat-state snapshot');
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
' const beforePosition = current.player.position;\n const beforeDistance = distance3(beforePosition, checkpoint.position);\n const planarRemaining = distancePlanar(beforePosition, checkpoint.position);\n const speedEstimate = ordinaryMovementSpeedEstimate(result.movement);\n const durationMs = Math.max(45, Math.min(350, Math.floor(planarRemaining * 0.58 / speedEstimate * 1_000)));\n const moved = await walkToward(page, result, checkpoint.position, durationMs);\n if (!moved) {\n routeBlocked = { kind: \'no-directional-input\', checkpointIndex, checkpoint, position: current.player.position };',
|
||||
' const beforePosition = current.player.position;\n const beforeDistance = distance3(beforePosition, checkpoint.position);\n const planarRemaining = distancePlanar(beforePosition, checkpoint.position);\n const speedEstimate = ordinaryMovementSpeedEstimate(result.movement);\n const recentResponseScales = result.movement.slice(-8)\n .filter((step) => Number(step.requestedDurationMs) > 0 && Number(step.durationMs) >= Number(step.requestedDurationMs)\n && Number(step.planarDisplacement) >= 0.08)\n .map((step) => Number(step.durationMs) / Number(step.requestedDurationMs)).sort((left, right) => left - right);\n const movementResponseScale = recentResponseScales.length\n ? recentResponseScales[Math.floor(recentResponseScales.length / 2)] : 1;\n const plannedDurationMs = planarRemaining * 0.58 / speedEstimate * 1_000 / movementResponseScale;\n const nearCheckpointLimitMs = planarRemaining < 1.5 ? 55 : 350;\n const durationMs = Math.max(25, Math.min(nearCheckpointLimitMs, 350, Math.floor(plannedDurationMs)));\n const moved = await walkToward(page, result, checkpoint.position, durationMs);\n if (!moved) {\n routeBlocked = { kind: \'no-directional-input\', checkpointIndex, checkpoint, position: current.player.position };',
|
||||
'adaptive ordinary movement timing from measured response');
|
||||
transformed = replaceExactlyOnce(transformed,
|
||||
' buildAssets: moduleFiles, error: error.stack ?? error.message, pageErrors, consoleErrors,',
|
||||
' buildAssets: moduleFiles, error: error.stack ?? error.message, pageErrors, consoleErrors,\n contentSourceResponses,',
|
||||
'failure content source evidence');
|
||||
if (/['"`]dist\/(?:assets|bundle-module-report)/.test(transformed)) {
|
||||
throw new Error('A hard-coded campaign harness distribution path survived frozen-build instrumentation.');
|
||||
}
|
||||
return transformed;
|
||||
}
|
||||
|
||||
function summarizeEnemyEvidence(route) {
|
||||
const mobSamples = [];
|
||||
const rawCombatEvents = route.observedCombatEvents ?? [];
|
||||
function visit(value, sourcePath = '$') {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
if (Array.isArray(value.mobs)) {
|
||||
const at = value.at ?? value.now ?? value.observedAt ?? null;
|
||||
for (const mob of value.mobs) {
|
||||
if (!mob || typeof mob !== 'object') continue;
|
||||
mobSamples.push({
|
||||
sampledAt: at,
|
||||
sourcePath,
|
||||
id: mob.id ?? null,
|
||||
name: mob.name ?? null,
|
||||
serverEntry: mob.serverEntry ?? null,
|
||||
health: mob.health ?? null,
|
||||
maxHealth: mob.maxHealth ?? null,
|
||||
dead: mob.dead ?? null,
|
||||
engaged: mob.engaged ?? null,
|
||||
activeCast: mob.activeCast ?? null,
|
||||
abilityCastCounts: mob.abilityCastCounts ?? null,
|
||||
attacks: mob.attacks ?? [],
|
||||
auras: mob.auras ?? [],
|
||||
combatAuraTelemetry: mob.combatAuraTelemetry ?? [],
|
||||
statuses: mob.statuses ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value)) value.forEach((child, index) => visit(child, `${sourcePath}[${index}]`));
|
||||
else for (const [key, child] of Object.entries(value)) visit(child, `${sourcePath}.${key}`);
|
||||
}
|
||||
visit(route);
|
||||
const enemyActorIds = new Set(mobSamples.map((sample) => sample.id).filter(Boolean));
|
||||
const enemyNameById = new Map(mobSamples.filter((sample) => sample.id && sample.name)
|
||||
.map((sample) => [sample.id, sample.name]));
|
||||
const combatAuraSamples = mobSamples.filter((sample) => sample.combatAuraTelemetry.length > 0)
|
||||
.map((sample) => ({ sampledAt: sample.sampledAt, sourcePath: sample.sourcePath,
|
||||
enemyId: sample.id, enemyName: sample.name, auras: sample.combatAuraTelemetry }));
|
||||
const enemyBuffAuraApplications = [...new Map(combatAuraSamples.flatMap((sample) => sample.auras
|
||||
.filter((aura) => enemyActorIds.has(aura.sourceId) && enemyActorIds.has(aura.targetId)
|
||||
&& aura.definition?.disposition === 'buff')
|
||||
.map((aura) => [aura.instanceId, {
|
||||
instanceId: aura.instanceId,
|
||||
definitionId: aura.definitionId,
|
||||
definitionName: aura.definitionName,
|
||||
sourceId: aura.sourceId,
|
||||
sourceName: enemyNameById.get(aura.sourceId) ?? null,
|
||||
targetId: aura.targetId,
|
||||
targetName: enemyNameById.get(aura.targetId) ?? null,
|
||||
appliedAt: aura.appliedAt,
|
||||
expiresAt: aura.expiresAt,
|
||||
stacks: aura.stacks,
|
||||
}]))).filter(([instanceId]) => Boolean(instanceId))].map(([, aura]) => aura);
|
||||
|
||||
const enemyDamageEvents = rawCombatEvents.filter((event) => enemyActorIds.has(event.sourceActorId)
|
||||
&& event.kind === 'damage').map((event) => ({ ...event,
|
||||
sourceEnemyName: enemyNameById.get(event.sourceActorId) ?? null }));
|
||||
const rendDamageEvents = enemyDamageEvents.filter((event) => /(?:^|\D)(?:6547|16509)(?:\D|$)/
|
||||
.test(String(event.abilityId ?? event.spellId ?? event.attackId ?? ''))
|
||||
|| /rend/i.test(String(event.abilityName ?? event.name ?? '')));
|
||||
const chainLightningCastSamples = mobSamples.filter((sample) => sample.name === FULL_STOCKADE_BOSS
|
||||
&& sample.activeCast && (Number(sample.activeCast.spellId) === 421
|
||||
|| /(?:^|\D)421(?:\D|$)/.test(String(sample.activeCast.attackId ?? ''))
|
||||
|| /chain\s*lightning/i.test(String(sample.activeCast.name ?? ''))));
|
||||
const rendCastSamples = mobSamples.filter((sample) => sample.activeCast
|
||||
&& (Number(sample.activeCast.spellId) === 6547 || Number(sample.activeCast.spellId) === 16509
|
||||
|| /rend/i.test(String(sample.activeCast.name ?? ''))
|
||||
|| /(?:^|\D)(?:6547|16509)(?:\D|$)/.test(String(sample.activeCast.attackId ?? ''))));
|
||||
const demoralizingShoutCastWindows = [...new Map(mobSamples.filter((sample) => sample.activeCast
|
||||
&& (Number(sample.activeCast.spellId) === 13730 || Number(sample.activeCast.spellId) === 16244
|
||||
|| /demoralizing\s*shout/i.test(String(sample.activeCast.name ?? '')))
|
||||
).map((sample) => [`${sample.id}|${sample.activeCast.attackId}|${sample.activeCast.startedAt}`, {
|
||||
enemyId: sample.id,
|
||||
enemyName: sample.name,
|
||||
attackId: sample.activeCast.attackId ?? null,
|
||||
spellId: sample.activeCast.spellId ?? null,
|
||||
startedAt: sample.activeCast.startedAt ?? null,
|
||||
completesAt: sample.activeCast.completesAt ?? null,
|
||||
interruptible: sample.activeCast.interruptible ?? null,
|
||||
}])).values()];
|
||||
const rendAttacks = mobSamples.flatMap((sample) => (sample.attacks ?? []).filter((attack) => (
|
||||
Number(attack.spellId) === 6547 || Number(attack.spellId) === 16509
|
||||
|| /rend/i.test(String(attack.name ?? ''))
|
||||
|| /(?:^|\D)(?:6547|16509)(?:\D|$)/.test(String(attack.id ?? ''))
|
||||
)).map((attack) => ({ enemyId: sample.id, enemyName: sample.name, ...attack })));
|
||||
const enemyAuraObservations = mobSamples.filter((sample) => Array.isArray(sample.auras) && sample.auras.length > 0)
|
||||
.map((sample) => ({ sampledAt: sample.sampledAt, sourcePath: sample.sourcePath,
|
||||
enemyId: sample.id, enemyName: sample.name, auras: sample.auras }));
|
||||
return {
|
||||
definitions: {
|
||||
rawCombatEvents: 'Unmodified combatStore event objects collected by the campaign harness; timestamps and amount fields retain source values.',
|
||||
enemyDamageEvents: 'Raw damage events whose source actor id appeared in observed mob snapshots.',
|
||||
rendDamageEvents: 'Raw enemy damage events whose source ability id/name matches Rend 6547 or 16509.',
|
||||
mobSamples: 'Read-only snapshots extended with each mob attack definition and mob aura state.',
|
||||
combatAuraSamples: 'Read-only combatStore auras filtered to the sampled mob id, retaining definition, owner, source, apply, expiry, and stack fields.',
|
||||
enemyBuffAuraApplications: 'Unique enemy-source buff auras whose source and target actor ids both appeared in sampled mobs; deduplicated by aura instance id.',
|
||||
chainLightningMatch: 'Hamhock active cast matches spellId 421, attackId containing 421, or source cast name Chain Lightning.',
|
||||
rendCastMatch: 'Mob active cast matches spellId 6547/16509, attackId containing either ID, or source cast name Rend.',
|
||||
demoralizingShoutCastWindows: 'Unique active cast windows for enemy spell 13730/16244 or a Demoralizing Shout-named cast, keyed by enemy id, attack id, and startedAt.',
|
||||
rendMatch: 'Mob attack definition matches spellId 6547/16509, attack name Rend, or attack id containing one of those IDs.',
|
||||
},
|
||||
counts: {
|
||||
mobSamples: mobSamples.length,
|
||||
rawCombatEvents: rawCombatEvents.length,
|
||||
enemyDamageEvents: enemyDamageEvents.length,
|
||||
rendDamageEvents: rendDamageEvents.length,
|
||||
chainLightningCastSamples: chainLightningCastSamples.length,
|
||||
rendCastSamples: rendCastSamples.length,
|
||||
demoralizingShoutCastWindows: demoralizingShoutCastWindows.length,
|
||||
rendAttackDefinitions: rendAttacks.length,
|
||||
enemyAuraObservations: enemyAuraObservations.length,
|
||||
combatAuraSamples: combatAuraSamples.length,
|
||||
enemyBuffAuraApplications: enemyBuffAuraApplications.length,
|
||||
},
|
||||
enemyDamageEvents,
|
||||
rendDamageEvents,
|
||||
chainLightningCastSamples,
|
||||
rendCastSamples,
|
||||
demoralizingShoutCastWindows,
|
||||
rendAttackDefinitions: [...new Map(rendAttacks.map((attack) => [
|
||||
`${attack.enemyId}|${attack.id}|${attack.spellId}`, attack,
|
||||
])).values()],
|
||||
enemyAuraObservations,
|
||||
combatAuraSamples,
|
||||
enemyBuffAuraApplications,
|
||||
mobSamples,
|
||||
rawCombatEvents,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeStanceBlockingEvidence(route) {
|
||||
const partyActors = new Map();
|
||||
const hostileActors = new Map();
|
||||
const partyName = route.profile?.name ?? route.playerAtEntry?.name ?? 'Player';
|
||||
partyActors.set('__player__', { id: '__player__', name: partyName, role: 'healer', actorKind: 'player' });
|
||||
|
||||
function addPartyActor(actor) {
|
||||
if (!actor?.id) return;
|
||||
partyActors.set(actor.id, { id: actor.id, name: actor.name ?? null,
|
||||
role: actor.role ?? null, classId: actor.classId ?? null,
|
||||
specializationId: actor.specializationId ?? null, actorKind: 'companion' });
|
||||
}
|
||||
function addHostileActor(actor) {
|
||||
if (!actor?.id) return;
|
||||
hostileActors.set(actor.id, { id: actor.id, name: actor.name ?? null,
|
||||
serverEntry: actor.serverEntry ?? null, actorKind: 'hostile-mob' });
|
||||
}
|
||||
function visitActorCollections(value) {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visitActorCollections);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value.party)) value.party.forEach(addPartyActor);
|
||||
if (Array.isArray(value.mobs)) value.mobs.forEach(addHostileActor);
|
||||
for (const child of Object.values(value)) visitActorCollections(child);
|
||||
}
|
||||
route.partyAtEntry?.forEach(addPartyActor);
|
||||
visitActorCollections(route.samples ?? []);
|
||||
visitActorCollections(route.packEncounters ?? []);
|
||||
visitActorCollections(route.bossEncounters ?? []);
|
||||
|
||||
const actorName = (id) => partyActors.get(id)?.name ?? hostileActors.get(id)?.name ?? null;
|
||||
const actorSide = (id) => partyActors.has(id) ? 'party' : hostileActors.has(id) ? 'hostile' : 'unresolved';
|
||||
const sampledAuraObservations = (route.samples ?? []).flatMap((sample) =>
|
||||
(sample.stanceBlockingAuraTelemetry ?? []).map((aura) => ({
|
||||
...aura, firstObservedAt: sample.at, lastObservedAt: sample.at,
|
||||
})));
|
||||
const auraApplicationsById = new Map();
|
||||
for (const aura of [...(route.observedStanceBlockingAuraObservations ?? []), ...sampledAuraObservations]) {
|
||||
const spellIdMatch = /(?:^|-)(7164|7165|3419)$/.exec(String(aura.definitionId ?? ''));
|
||||
const spellId = spellIdMatch ? Number(spellIdMatch[1]) : null;
|
||||
if (![7164, 7165, 3419].includes(spellId)) continue;
|
||||
const applicationId = [aura.instanceId ?? '', aura.appliedAt ?? '', aura.definitionId ?? '',
|
||||
aura.sourceId ?? '', aura.targetId ?? ''].join('|');
|
||||
const current = auraApplicationsById.get(applicationId);
|
||||
if (!current || Number(aura.lastObservedAt ?? 0) > Number(current.lastObservedAt ?? 0)) {
|
||||
auraApplicationsById.set(applicationId, { ...aura, spellId });
|
||||
}
|
||||
}
|
||||
const auraApplications = [...auraApplicationsById.values()].map((aura) => ({
|
||||
instanceId: aura.instanceId ?? null,
|
||||
spellId: aura.spellId,
|
||||
definitionId: aura.definitionId ?? null,
|
||||
name: aura.definitionName ?? (aura.spellId === 7164 ? 'Defensive Stance'
|
||||
: aura.spellId === 7165 ? 'Battle Stance' : 'Improved Blocking'),
|
||||
sourceActorId: aura.sourceId ?? null,
|
||||
sourceActorName: actorName(aura.sourceId),
|
||||
sourceActorSide: actorSide(aura.sourceId),
|
||||
targetActorId: aura.targetId ?? null,
|
||||
targetActorName: actorName(aura.targetId),
|
||||
targetActorSide: actorSide(aura.targetId),
|
||||
appliedAt: aura.appliedAt ?? null,
|
||||
expiresAt: aura.expiresAt ?? null,
|
||||
stacks: aura.stacks ?? null,
|
||||
firstObservedAt: aura.firstObservedAt ?? null,
|
||||
lastObservedAt: aura.lastObservedAt ?? null,
|
||||
telemetry: 'active combatStore aura state from collector observations and route samples, deduplicated by instanceId + appliedAt',
|
||||
}));
|
||||
const stanceAuraApplications = auraApplications.filter((aura) => aura.spellId === 7164 || aura.spellId === 7165);
|
||||
const improvedBlockingAuraApplications = auraApplications.filter((aura) => aura.spellId === 3419);
|
||||
|
||||
const blockResultByEventId = new Map();
|
||||
const unmatchedBlockResultObservations = [];
|
||||
for (const observation of route.observedBlockResultObservations ?? []) {
|
||||
const eventId = observation.event?.id;
|
||||
if (eventId != null) blockResultByEventId.set(eventId, observation);
|
||||
else unmatchedBlockResultObservations.push(observation);
|
||||
}
|
||||
const rawBlockEvents = (route.observedCombatEvents ?? []).filter((event) => event.kind === 'damage'
|
||||
&& event.outcome === 'block');
|
||||
const blockEvents = rawBlockEvents.map((event) => {
|
||||
const sourceActorSide = actorSide(event.sourceActorId);
|
||||
const targetActorSide = actorSide(event.targetActorId);
|
||||
const direction = sourceActorSide === 'hostile' && targetActorSide === 'party'
|
||||
? 'hostile-attack-blocked-by-party'
|
||||
: sourceActorSide === 'party' && targetActorSide === 'hostile'
|
||||
? 'party-attack-blocked-by-hostile' : 'actor-team-unresolved';
|
||||
const resultObservation = blockResultByEventId.get(event.id) ?? null;
|
||||
return {
|
||||
eventId: event.id ?? null,
|
||||
occurredAt: event.occurredAt ?? null,
|
||||
direction,
|
||||
sourceActorId: event.sourceActorId ?? null,
|
||||
sourceActorName: actorName(event.sourceActorId),
|
||||
sourceActorSide,
|
||||
targetActorId: event.targetActorId ?? null,
|
||||
targetActorName: actorName(event.targetActorId),
|
||||
targetActorSide,
|
||||
abilityId: event.abilityId ?? null,
|
||||
school: event.school ?? null,
|
||||
outcome: event.outcome ?? null,
|
||||
rawAmount: event.rawAmount ?? null,
|
||||
effectiveAmount: event.effectiveAmount ?? null,
|
||||
absorbed: event.absorbed ?? null,
|
||||
blockedAmount: resultObservation?.combatResult?.blockedAmount ?? null,
|
||||
blockResultCorrelation: resultObservation?.correlation ?? 'event outcome only; no matched lastCombatResult amount sample',
|
||||
lastCombatResult: resultObservation?.combatResult ?? null,
|
||||
};
|
||||
});
|
||||
const partyDefenseBlockEvents = blockEvents.filter((event) => event.direction === 'hostile-attack-blocked-by-party');
|
||||
const partyAttackBlockedEvents = blockEvents.filter((event) => event.direction === 'party-attack-blocked-by-hostile');
|
||||
return {
|
||||
definitions: {
|
||||
stanceAuraApplications: 'Unique active aura applications for source spells 7164 Defensive Stance and 7165 Battle Stance, keyed by aura instance id; application/source/target timestamps and expiry are preserved.',
|
||||
improvedBlockingAuraApplications: 'Unique active aura applications for source spell 3419 Improved Blocking, keyed by aura instance id plus appliedAt and reconstructed from collector observations plus route samples.',
|
||||
blockEvents: 'Raw read-only combatStore damage events with outcome=block. Direction is classified from party actor IDs and sampled hostile mob IDs.',
|
||||
blockedAmount: 'Copied from sampled lastCombatResult only when its outcome and raw damage amount match the latest damage event in that snapshot; unmatched block outcomes remain recorded with blockedAmount=null.',
|
||||
samplingLimit: 'Aura telemetry and lastCombatResult are sampled when the campaign harness reads live state; short intervals may be missed if they begin and expire between reads.',
|
||||
},
|
||||
counts: {
|
||||
stanceAuraApplications: stanceAuraApplications.length,
|
||||
defensiveStanceApplications: stanceAuraApplications.filter((aura) => aura.spellId === 7164).length,
|
||||
battleStanceApplications: stanceAuraApplications.filter((aura) => aura.spellId === 7165).length,
|
||||
improvedBlockingAuraApplications: improvedBlockingAuraApplications.length,
|
||||
rawBlockEvents: rawBlockEvents.length,
|
||||
partyDefenseBlockEvents: partyDefenseBlockEvents.length,
|
||||
partyAttackBlockedEvents: partyAttackBlockedEvents.length,
|
||||
actorTeamUnresolvedBlockEvents: blockEvents.filter((event) => event.direction === 'actor-team-unresolved').length,
|
||||
matchedBlockedAmountEvents: blockEvents.filter((event) => event.blockedAmount !== null).length,
|
||||
unmatchedBlockResultObservations: unmatchedBlockResultObservations.length,
|
||||
},
|
||||
partyActors: [...partyActors.values()],
|
||||
hostileActors: [...hostileActors.values()],
|
||||
auraApplications,
|
||||
stanceAuraApplications,
|
||||
improvedBlockingAuraApplications,
|
||||
blockEvents,
|
||||
unmatchedBlockResultObservations,
|
||||
rawBlockEvents,
|
||||
};
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const instrumentedHarness = createInstrumentedHarness();
|
||||
fs.writeFileSync(runtimeHarnessPath, instrumentedHarness, 'utf8');
|
||||
try {
|
||||
const syntax = spawnSync(process.execPath, ['--check', runtimeHarnessPath], {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
});
|
||||
if (syntax.status !== 0) throw new Error(`Instrumented campaign harness syntax check failed: ${syntax.stderr || syntax.error}`);
|
||||
|
||||
if (prepareOnly) {
|
||||
console.log('Prepared scoped Stockade stance/blocking harness; source and transformed syntax are valid; no distribution scan, server, or browser was started.');
|
||||
return;
|
||||
}
|
||||
if (!process.argv.includes('--full-stockade')) {
|
||||
throw new Error('This source-specific full-campaign harness requires explicit --full-stockade opt-in.');
|
||||
}
|
||||
if (!frozenBuildAuthorized) {
|
||||
throw new Error('Live mode is locked until the owner authorizes the frozen build. Use --prepare-only for static preparation.');
|
||||
}
|
||||
if (!requestedBuildDirectory) {
|
||||
throw new Error('Live mode requires HEALER_MAN_STOCKADE_DIST to point to the immutable task-specific distribution.');
|
||||
}
|
||||
if (!expectedBuildSha256 || !/^[a-f0-9]{64}$/i.test(expectedBuildSha256)) {
|
||||
throw new Error('Live mode requires --expected-build-sha256=<64 hex chars> or HEALER_MAN_STOCKADE_DIST_SHA256.');
|
||||
}
|
||||
if (!fs.existsSync(requestedBuildDirectory) || !fs.statSync(requestedBuildDirectory).isDirectory()) {
|
||||
throw new Error(`Task-specific frozen distribution is missing: ${requestedBuildDirectory}`);
|
||||
}
|
||||
const projectDistRealPath = fs.realpathSync(path.join(projectRoot, 'dist'));
|
||||
const requestedDistRealPath = fs.realpathSync(requestedBuildDirectory);
|
||||
if (requestedDistRealPath === projectDistRealPath) {
|
||||
throw new Error('Live mode requires the isolated frozen distribution copy, not the mutable project dist directory.');
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
const beforeManifest = collectBuildManifest();
|
||||
const contentManifestBefore = collectContentManifestSnapshot();
|
||||
writeJson(path.join(outputDirectory, 'build-manifest-before.json'), beforeManifest);
|
||||
writeJson(path.join(outputDirectory, 'content-manifest-before.json'), contentManifestBefore);
|
||||
const runRecord = {
|
||||
status: 'running',
|
||||
runId,
|
||||
startedAt: new Date().toISOString(),
|
||||
dungeon: { id: FULL_STOCKADE_DUNGEON_ID, title: FULL_STOCKADE_DUNGEON_TITLE },
|
||||
profilePlan: { creation: 'normal offline WoW character-creation UI', classId: 'priest',
|
||||
specializationId: 'priest:discipline', role: 'healer', level: 30,
|
||||
companions: 'four NPC companions, each asserted against the five Ready specialization IDs' },
|
||||
entryAndPlay: 'normal full Stockade picker and Discipline healer-role entry; campaign harness ordinary WASD, target selection, attack/support healing/party commands; no Boss Testing, teleport, collision bypass, enemy/damage/aura/health/cast seeding, or combat store writes',
|
||||
campaignHarness: path.relative(projectRoot, sourceHarnessPath),
|
||||
instrumentation: ['active enemy-source aura applications for spells 7164/7165/3419 with owner, target, apply/expiry, and stack fields',
|
||||
'raw observed combat events with block outcomes and actor direction',
|
||||
'read-only lastCombatResult block amount snapshots correlated to matching damage events',
|
||||
'read-only live-state progress.json snapshots at 30-second intervals'],
|
||||
requestedMaximumCampaignSeconds: 1800,
|
||||
frozenBuildDirectory: requestedDistRealPath,
|
||||
expectedBuildSha256: expectedBuildSha256.toLowerCase(),
|
||||
readOnlyContentSourceDirectory: contentSourceDirectory,
|
||||
contentManifestBeforeSha256: contentManifestBefore.manifestSha256,
|
||||
childOutputDirectory: path.relative(projectRoot, childOutputDirectory),
|
||||
progressFile: path.relative(projectRoot, path.join(outputDirectory, 'progress.json')),
|
||||
progressSnapshotIntervalSeconds: 30,
|
||||
buildManifestBeforeSha256: beforeManifest.aggregateSha256,
|
||||
loadedBuildAssets: [],
|
||||
statusNotes: [],
|
||||
};
|
||||
writeJson(path.join(outputDirectory, 'run-record.json'), runRecord);
|
||||
|
||||
if (beforeManifest.aggregateSha256.toLowerCase() !== expectedBuildSha256.toLowerCase()) {
|
||||
runRecord.status = 'not-started-frozen-build-fingerprint-mismatch';
|
||||
runRecord.executionStatus = 'browser-not-started';
|
||||
runRecord.statusNotes.push('Expected aggregate SHA-256 did not match the supplied frozen distribution; no game server or browser was started.');
|
||||
writeJson(path.join(outputDirectory, 'run-record.json'), runRecord);
|
||||
console.error(`Frozen-build fingerprint mismatch; preserved preflight evidence at ${path.relative(projectRoot, outputDirectory)}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawnSync(process.execPath, [runtimeHarnessPath,
|
||||
'--campaign-boss-route', `--route=${FULL_STOCKADE_DUNGEON_ID}`, '--max-campaign-seconds=1800'], {
|
||||
cwd: projectRoot,
|
||||
env: { ...process.env, HEALER_MAN_STOCKADE_DIST: requestedDistRealPath,
|
||||
HEALER_MAN_STOCKADE_PROGRESS_PATH: path.join(outputDirectory, 'progress.json') },
|
||||
stdio: 'inherit',
|
||||
timeout: 2_100_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const afterManifest = collectBuildManifest();
|
||||
const contentManifestAfter = collectContentManifestSnapshot();
|
||||
writeJson(path.join(outputDirectory, 'build-manifest-after.json'), afterManifest);
|
||||
writeJson(path.join(outputDirectory, 'content-manifest-after.json'), contentManifestAfter);
|
||||
const candidateDirectories = fs.existsSync(childOutputDirectory)
|
||||
? fs.readdirSync(childOutputDirectory, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory()).map((entry) => path.join(childOutputDirectory, entry.name)).sort()
|
||||
: [];
|
||||
const campaignDirectory = candidateDirectories.at(-1) ?? null;
|
||||
const routePath = campaignDirectory
|
||||
? path.join(campaignDirectory, `${FULL_STOCKADE_DUNGEON_ID}-campaign-boss-route.json`) : null;
|
||||
const routeResultPath = campaignDirectory
|
||||
? path.join(campaignDirectory, `${FULL_STOCKADE_DUNGEON_ID}-route.json`) : null;
|
||||
const resultsPath = campaignDirectory ? path.join(campaignDirectory, 'results.json') : null;
|
||||
const route = routePath && fs.existsSync(routePath) ? JSON.parse(fs.readFileSync(routePath, 'utf8')) : null;
|
||||
const routeResult = routeResultPath && fs.existsSync(routeResultPath)
|
||||
? JSON.parse(fs.readFileSync(routeResultPath, 'utf8')) : null;
|
||||
const results = resultsPath && fs.existsSync(resultsPath) ? JSON.parse(fs.readFileSync(resultsPath, 'utf8')) : null;
|
||||
const evidence = route ? summarizeStanceBlockingEvidence(route) : null;
|
||||
if (evidence) writeJson(path.join(outputDirectory, 'stances-blocking-evidence.json'), evidence);
|
||||
const failurePath = campaignDirectory ? path.join(campaignDirectory, 'failure.json') : null;
|
||||
const failure = failurePath && fs.existsSync(failurePath)
|
||||
? JSON.parse(fs.readFileSync(failurePath, 'utf8')) : null;
|
||||
const contentSourceResponses = route?.contentSourceResponses ?? routeResult?.contentSourceResponses
|
||||
?? failure?.contentSourceResponses ?? [];
|
||||
const loadedContentObjects = collectLoadedContentObjectEvidence(contentSourceResponses);
|
||||
const contentSourceEvidence = {
|
||||
sourceDirectory: contentSourceDirectory,
|
||||
manifestBefore: contentManifestBefore,
|
||||
manifestAfter: contentManifestAfter,
|
||||
manifestUnchanged: contentManifestBefore.manifestSha256 === contentManifestAfter.manifestSha256,
|
||||
responses: contentSourceResponses,
|
||||
loadedObjects: loadedContentObjects,
|
||||
counts: {
|
||||
manifestResponses: contentSourceResponses.filter((response) => response?.pathname === '/content/manifest.json').length,
|
||||
successfulManifestResponses: contentSourceResponses.filter((response) => response?.pathname === '/content/manifest.json'
|
||||
&& response.status === 200).length,
|
||||
objectResponses: contentSourceResponses.filter((response) => response?.pathname?.startsWith('/content/objects/')).length,
|
||||
successfulObjectResponses: contentSourceResponses.filter((response) => response?.pathname?.startsWith('/content/objects/')
|
||||
&& response.status === 200).length,
|
||||
verifiedHashAddressedObjects: loadedContentObjects.filter((object) => object.objectIntegrity === 'served-file-matches-hash-address').length,
|
||||
objectIntegrityFailures: loadedContentObjects.filter((object) => object.objectIntegrity === 'served-file-hash-mismatch'
|
||||
|| object.objectIntegrity === 'not-found-on-content-source' || object.objectIntegrity === 'unsafe-path-refused').length,
|
||||
},
|
||||
};
|
||||
writeJson(path.join(outputDirectory, 'content-source-evidence.json'), contentSourceEvidence);
|
||||
|
||||
runRecord.endedAt = new Date().toISOString();
|
||||
runRecord.executionStatus = child.error ? 'failed-to-complete' : child.status === 0 ? 'complete' : 'failed';
|
||||
runRecord.childExitCode = child.status;
|
||||
runRecord.childSignal = child.signal ?? null;
|
||||
runRecord.childError = child.error ? { message: child.error.message, code: child.error.code ?? null } : null;
|
||||
runRecord.campaignDirectory = campaignDirectory ? path.relative(projectRoot, campaignDirectory) : null;
|
||||
runRecord.campaignRoutePath = routePath && fs.existsSync(routePath) ? path.relative(projectRoot, routePath) : null;
|
||||
runRecord.loadedBuildAssets = results?.buildAssets ?? [];
|
||||
runRecord.actualAssetUrls = route?.actualAssetUrls ?? [];
|
||||
runRecord.campaignResultStatus = results?.status ?? null;
|
||||
const expectedBossIds = route?.bossProgress?.orderedBossIds ?? [];
|
||||
const killedBossIds = new Set(route?.bossProgress?.latestKilledBossIds ?? []);
|
||||
const fiveRequiredBossesVerified = expectedBossIds.length === 5
|
||||
&& expectedBossIds.every((bossId) => killedBossIds.has(bossId));
|
||||
const fullClearRequirementsVerified = Boolean(results?.status === 'verified-full-clear'
|
||||
&& results?.campaignFullClearVerified === true
|
||||
&& route?.fullClearStatus === 'native-full-dungeon-clear-and-all-required-killed-boss-ids-observed'
|
||||
&& route?.dungeonResult?.fullClearVisible === true
|
||||
&& route?.dungeonResult?.requiredKilledBossIdsComplete === true
|
||||
&& fiveRequiredBossesVerified);
|
||||
runRecord.campaignHarnessFullClearVerified = results?.campaignFullClearVerified ?? false;
|
||||
runRecord.fullClearRequirementsVerified = fullClearRequirementsVerified;
|
||||
runRecord.requiredBossIds = expectedBossIds;
|
||||
runRecord.killedBossIds = [...killedBossIds];
|
||||
runRecord.fullClearVerified = fullClearRequirementsVerified;
|
||||
runRecord.bossProgress = route?.bossProgress ?? null;
|
||||
runRecord.dungeonResult = route?.dungeonResult ?? null;
|
||||
runRecord.stanceBlockingEvidenceCounts = evidence?.counts ?? null;
|
||||
runRecord.contentManifestAfterSha256 = contentManifestAfter.manifestSha256;
|
||||
runRecord.contentManifestUnchanged = contentSourceEvidence.manifestUnchanged;
|
||||
runRecord.contentSourceResponseCounts = contentSourceEvidence.counts;
|
||||
runRecord.contentSourceEvidencePath = path.relative(projectRoot, path.join(outputDirectory, 'content-source-evidence.json'));
|
||||
runRecord.buildManifestAfterSha256 = afterManifest.aggregateSha256;
|
||||
runRecord.buildManifestUnchanged = beforeManifest.aggregateSha256 === afterManifest.aggregateSha256
|
||||
&& afterManifest.aggregateSha256.toLowerCase() === expectedBuildSha256.toLowerCase();
|
||||
runRecord.pageErrors = results?.pageErrors ?? null;
|
||||
runRecord.consoleErrors = results?.consoleErrors ?? null;
|
||||
runRecord.screenshots = campaignDirectory
|
||||
? fs.readdirSync(campaignDirectory).filter((file) => file.toLowerCase().endsWith('.png'))
|
||||
.map((file) => path.relative(projectRoot, path.join(campaignDirectory, file))).sort()
|
||||
: [];
|
||||
if (route && !evidence?.counts.stanceAuraApplications) {
|
||||
runRecord.statusNotes.push('No live application of Defensive Stance 7164 or Battle Stance 7165 was captured; aura outcomes are not observed in this run.');
|
||||
}
|
||||
if (route && !evidence?.counts.improvedBlockingAuraApplications) {
|
||||
runRecord.statusNotes.push('No live Improved Blocking 3419 application was captured; aura outcomes are not observed in this run.');
|
||||
}
|
||||
if (route && !evidence?.counts.rawBlockEvents) {
|
||||
runRecord.statusNotes.push('No damage event with outcome=block was captured; intermittent live sampling limits negative conclusions.');
|
||||
} else if (route && evidence?.counts.matchedBlockedAmountEvents < evidence?.counts.rawBlockEvents) {
|
||||
runRecord.statusNotes.push(`${evidence.counts.rawBlockEvents - evidence.counts.matchedBlockedAmountEvents} block outcome event(s) had no matching sampled lastCombatResult amount; the outcome is retained without inferring a blocked amount.`);
|
||||
}
|
||||
if (route && evidence?.counts.actorTeamUnresolvedBlockEvents) {
|
||||
runRecord.statusNotes.push(`${evidence.counts.actorTeamUnresolvedBlockEvents} block event(s) could not be assigned to party-versus-hostile direction from sampled actor IDs.`);
|
||||
}
|
||||
if (!runRecord.fullClearVerified && runRecord.executionStatus === 'complete' && route) {
|
||||
runRecord.statusNotes.push('Campaign process exited successfully, but results UI plus all five required boss IDs were not verified; this remains a partial live attempt.');
|
||||
}
|
||||
if (!runRecord.buildManifestUnchanged) {
|
||||
runRecord.statusNotes.push('The frozen distribution fingerprint changed during the run; preserve the evidence but do not attribute it to one immutable build.');
|
||||
}
|
||||
if (!runRecord.contentManifestUnchanged) {
|
||||
runRecord.statusNotes.push('The read-only content manifest changed during the run; content source provenance is inconclusive.');
|
||||
}
|
||||
runRecord.status = runRecord.executionStatus === 'complete' && route
|
||||
? runRecord.fullClearVerified ? 'complete' : 'partial-full-clear-unverified' : 'failed';
|
||||
if (!runRecord.buildManifestUnchanged && runRecord.status === 'complete') runRecord.status = 'inconclusive-build-mutated';
|
||||
writeJson(path.join(outputDirectory, 'run-record.json'), runRecord);
|
||||
console.log(`Stockade stance/blocking QA ${runRecord.status}: ${path.relative(projectRoot, outputDirectory)}`);
|
||||
if (runRecord.status !== 'complete') process.exitCode = child.status || 1;
|
||||
} finally {
|
||||
fs.rmSync(runtimeHarnessPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
fs.rmSync(runtimeHarnessPath, { force: true });
|
||||
console.error(error.stack ?? error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Reference in new issue
Block a user