532 lines
34 KiB
JavaScript
532 lines
34 KiB
JavaScript
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-enemy-mechanics', runId);
|
|
const childOutputDirectory = path.join(outputDirectory, 'campaign');
|
|
const runtimeHarnessPath = path.join(__dirname, `.playtest-stockade-enemy-mechanics-${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 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');
|
|
|
|
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,
|
|
' 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,
|
|
' 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 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 enemy-mechanics 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 character-creation UI', classId: 'rom-priest',
|
|
secondaryClassId: 'rom-knight', isolatedLevel: 30, pairingSetup: 'existing campaign harness shell profile setup before dungeon entry' },
|
|
entryAndPlay: 'normal full Stockade picker and 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: ['mob attack definitions', 'mob auras', 'combat auras filtered to each enemy id with definitions/owners/timestamps/stacks', 'raw observed combat events'],
|
|
requestedMaximumCampaignSeconds: 1800,
|
|
frozenBuildDirectory: requestedDistRealPath,
|
|
expectedBuildSha256: expectedBuildSha256.toLowerCase(),
|
|
readOnlyContentSourceDirectory: contentSourceDirectory,
|
|
contentManifestBeforeSha256: contentManifestBefore.manifestSha256,
|
|
childOutputDirectory: path.relative(projectRoot, childOutputDirectory),
|
|
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 },
|
|
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 ? summarizeEnemyEvidence(route) : null;
|
|
if (evidence) writeJson(path.join(outputDirectory, 'enemy-mechanics-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;
|
|
runRecord.fullClearVerified = results?.campaignFullClearVerified ?? false;
|
|
runRecord.bossProgress = route?.bossProgress ?? null;
|
|
runRecord.dungeonResult = route?.dungeonResult ?? null;
|
|
runRecord.enemyMechanicsEvidenceCounts = 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.chainLightningCastSamples) {
|
|
runRecord.statusNotes.push('Hamhock Chain Lightning live cast was not captured; inspect target cast coverage and label inconclusive if the boss was not observed or no matching cast entered sampled state.');
|
|
}
|
|
if (route && !evidence?.counts.rendAttackDefinitions && !evidence?.counts.rendDamageEvents) {
|
|
runRecord.statusNotes.push('No sampled mob exposed Rend 6547/16509 or a Rend-named attack definition; label source mechanics coverage inconclusive.');
|
|
}
|
|
if (route && !evidence?.counts.rendCastSamples) {
|
|
runRecord.statusNotes.push(evidence?.counts.rendDamageEvents
|
|
? 'Enemy Rend 6547/16509 damage was observed in the raw combat stream; no corresponding active-cast state was sampled.'
|
|
: 'No live mob Rend 6547/16509 cast entered a sampled active-cast state; attack definitions alone do not establish a cast.');
|
|
}
|
|
if (route && !evidence?.counts.enemyAuraObservations && evidence?.counts.enemyBuffAuraApplications) {
|
|
runRecord.statusNotes.push(`Mob aura snapshots remained empty, but ${evidence.counts.enemyBuffAuraApplications} unique enemy-source buff applications were captured from combat aura telemetry.`);
|
|
} else if (route && !evidence?.counts.enemyAuraObservations && !evidence?.counts.enemyBuffAuraApplications) {
|
|
runRecord.statusNotes.push('No non-empty mob aura snapshot or enemy-source buff application was observed; report enemy-buff outcome as not observed.');
|
|
}
|
|
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 ? 'complete' : 'failed';
|
|
if (!runRecord.buildManifestUnchanged && runRecord.status === 'complete') runRecord.status = 'inconclusive-build-mutated';
|
|
writeJson(path.join(outputDirectory, 'run-record.json'), runRecord);
|
|
console.log(`Stockade enemy-mechanics 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;
|
|
}
|