feat: reveal resonance pursuit outcomes

This commit is contained in:
2026-07-11 14:54:13 +09:00
parent 5b6ffa28f2
commit a0780a7ac6
2 changed files with 1288 additions and 76 deletions

View File

@@ -544,6 +544,895 @@ try {
`Expected the pursuit-ready UI fixture to restore battle positions, intents, acted state, camera, selection, markers, and idle HUD before the existing RC flow: ${JSON.stringify(firstBattleBondChainReadyUi)}`
);
const firstBattleBondChainJudgement = await page.evaluate(async () => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const stateBefore = window.__HEROS_DEBUG__?.battle();
const mechanics = stateBefore?.combatMechanicsProbe;
const attackerId = mechanics?.attackerId;
const partnerId = mechanics?.partnerId;
const targetId = mechanics?.enemyId;
if (
!scene ||
!attackerId ||
!partnerId ||
!targetId ||
stateBefore?.phase !== 'idle' ||
stateBefore.selectedUnitId !== null ||
stateBefore.commandMenuVisible !== false
) {
return {
ready: false,
reason: 'first battle was not in a clean idle state for the resonance judgement fixture',
stateBefore,
mechanics
};
}
const clone = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
const comparableSave = (state) => {
const { savedAt: _savedAt, ...comparable } = state;
return comparable;
};
const collectText = (objects) => {
const texts = [];
const visited = new Set();
const visit = (object) => {
if (!object || visited.has(object)) {
return;
}
visited.add(object);
if (object.type === 'Text' && typeof object.text === 'string') {
texts.push(object.text);
}
if (Array.isArray(object.list)) {
object.list.forEach(visit);
}
};
objects.forEach(visit);
return texts;
};
const sceneObjectsCreatedAfter = (before) => scene.children.list.filter((object) => !before.has(object));
const clearCreatedSceneObjects = (objects) => {
objects.forEach((object) => {
scene.tweens?.killTweensOf(object);
if (object?.active) {
object.destroy();
}
});
};
const setDebugForceHit = (enabled) => {
const url = new URL(window.location.href);
if (enabled) {
url.searchParams.set('debugForceHit', '1');
} else {
url.searchParams.delete('debugForceHit');
}
window.history.replaceState({}, '', url);
};
const setDebugForceBondChain = (value) => {
const url = new URL(window.location.href);
if (value === null) {
url.searchParams.delete('debugForceBondChain');
} else {
url.searchParams.set('debugForceBondChain', value);
}
window.history.replaceState({}, '', url);
};
const statsFor = (snapshot, unitId) => clone(snapshot?.[unitId] ?? {});
const statsDelta = (before, after) => Object.fromEntries(
Array.from(new Set([...Object.keys(before), ...Object.keys(after)])).map((key) => [
key,
(after[key] ?? 0) - (before[key] ?? 0)
])
);
const originalUrl = window.location.href;
const originalSave = clone(scene.createBattleSaveState());
const originalCamera = { x: scene.cameraTileX, y: scene.cameraTileY };
const originalJudgement = clone(scene.bondChainJudgementLast);
const originalPartnerClassKey = scene.debugUnitById(partnerId)?.classKey;
const originalBattleSpeed = scene.battleSpeed;
const originalFastForwardHeld = scene.fastForwardHeld;
const originalRollPercent = scene.rollPercent;
const hadOwnRollPercent = Object.prototype.hasOwnProperty.call(scene, 'rollPercent');
const originalPhase = scene.phase;
const originalSelectedUnit = scene.selectedUnit;
const originalPendingMove = scene.pendingMove;
const originalTargetingAction = scene.targetingAction;
const originalSelectedUsable = scene.selectedUsable;
const originalLockedTargetPreview = scene.lockedTargetPreview;
const restoreFixtureState = () => {
scene.hideCombatCutIn();
scene.applyBattleSaveState(clone(originalSave));
scene.cameraTileX = originalCamera.x;
scene.cameraTileY = originalCamera.y;
scene.updateCameraView();
scene.phase = originalPhase;
scene.selectedUnit = originalSelectedUnit;
scene.pendingMove = originalPendingMove;
scene.targetingAction = originalTargetingAction;
scene.selectedUsable = originalSelectedUsable;
scene.lockedTargetPreview = originalLockedTargetPreview;
scene.bondChainJudgementLast = clone(originalJudgement);
const partner = scene.debugUnitById(partnerId);
if (partner && originalPartnerClassKey) {
partner.classKey = originalPartnerClassKey;
}
scene.battleSpeed = originalBattleSpeed;
scene.fastForwardHeld = originalFastForwardHeld;
scene.renderBattleSpeedControl();
scene.clearMarkers();
scene.hideCommandMenu();
};
const prepareScenario = (mode, partnerClassKey) => {
restoreFixtureState();
const attacker = scene.debugUnitById(attackerId);
const partner = scene.debugUnitById(partnerId);
const target = scene.debugUnitById(targetId);
if (!attacker || !partner || !target) {
throw new Error(`Missing judgement fixture units: ${JSON.stringify({ attackerId, partnerId, targetId })}`);
}
if (partnerClassKey) {
partner.classKey = partnerClassKey;
}
attacker.x = 4;
attacker.y = 15;
partner.x = 3;
partner.y = 15;
target.x = 5;
target.y = 15;
target.maxHp = Math.max(target.maxHp, 5000);
target.hp = target.maxHp;
scene.attackIntents = [{ attackerId: partner.id, targetId: target.id }];
scene.actedUnitIds = new Set([partner.id]);
scene.centerCameraOnTile(target.x, target.y);
[attacker, partner, target].forEach((unit) => scene.positionUnitView(unit));
const preview = scene.combatPreview(attacker, target, 'attack');
if (!(preview.damage > 0 && preview.bondChainDamage > 0 && preview.bondChainRate > 0)) {
throw new Error(`Expected a live resonance preview in ${mode}: ${JSON.stringify({
damage: preview.damage,
rate: preview.bondChainRate,
chainDamage: preview.bondChainDamage,
partnerId: preview.bondChainPartnerId
})}`);
}
target.hp = mode === 'lethal'
? preview.damage
: preview.damage + preview.bondChainDamage;
return { attacker, partner, target, preview };
};
const captureNeutralMapCue = async (result) => {
const before = new Set(scene.children.list);
const effect = scene.showBondMapEffect(result);
const created = sceneObjectsCreatedAfter(before);
const texts = collectText(created);
await effect;
return texts;
};
const captureMapResult = (result) => {
const before = new Set(scene.children.list);
scene.showCombatMapResult(result);
const created = sceneObjectsCreatedAfter(before);
const texts = collectText(created);
clearCreatedSceneObjects(created);
return texts;
};
const presentCombatResult = async (result) => {
let presented = null;
let settled = false;
const telemetryStartedAt = performance.now();
const telemetry = { gauge: [], impact: [], partnerMotion: [] };
const originalAnimateCombatGauge = scene.animateCombatGauge;
const originalShowCombatImpact = scene.showCombatImpact;
const originalPlayCombatActionMotion = scene.playCombatActionMotion;
const methodOwnership = {
animateCombatGauge: Object.prototype.hasOwnProperty.call(scene, 'animateCombatGauge'),
showCombatImpact: Object.prototype.hasOwnProperty.call(scene, 'showCombatImpact'),
playCombatActionMotion: Object.prototype.hasOwnProperty.call(scene, 'playCombatActionMotion')
};
const elapsed = () => performance.now() - telemetryStartedAt;
const isPartnerResult = (candidate) =>
Boolean(result.bondChain) && candidate?.attacker?.id === result.bondChain.partnerId;
const restoreMethod = (name, original) => {
if (methodOwnership[name]) {
scene[name] = original;
} else {
delete scene[name];
}
};
scene.animateCombatGauge = function (fill, from, to, duration) {
const isChainGauge = Boolean(result.bondChain) &&
duration === 300 &&
Math.abs(from - result.bondChain.previousDefenderHp / result.defender.maxHp) < 0.0001 &&
Math.abs(to - result.defender.hp / result.defender.maxHp) < 0.0001;
const event = isChainGauge
? { startAt: elapsed(), endAt: null, from, to, duration, startScale: fill.scaleX, endScale: null }
: null;
if (event) {
telemetry.gauge.push(event);
}
const animation = originalAnimateCombatGauge.call(this, fill, from, to, duration);
return animation.then(() => {
if (event) {
event.endAt = elapsed();
event.endScale = fill.scaleX;
}
});
};
scene.showCombatImpact = function (impactResult, ...args) {
if (isPartnerResult(impactResult)) {
telemetry.impact.push({ at: elapsed(), damage: impactResult.damage });
}
return originalShowCombatImpact.call(this, impactResult, ...args);
};
scene.playCombatActionMotion = async function (motionResult, ...args) {
const isPartnerMotion = isPartnerResult(motionResult);
const event = isPartnerMotion ? { startAt: elapsed(), endAt: null } : null;
if (event) {
telemetry.partnerMotion.push(event);
}
await originalPlayCombatActionMotion.call(this, motionResult, ...args);
if (event) {
event.endAt = elapsed();
}
};
try {
const playback = scene.playCombatCutIn(result).finally(() => {
settled = true;
});
for (let attempt = 0; attempt < 240 && !settled; attempt += 1) {
const judgement = scene.getDebugState().bondChainJudgement;
if (judgement?.presented && !judgement.completed) {
presented = clone(judgement);
break;
}
await new Promise((resolve) => window.setTimeout(resolve, 25));
}
await playback;
return {
presented,
completed: clone(scene.getDebugState().bondChainJudgement),
telemetry
};
} finally {
restoreMethod('animateCombatGauge', originalAnimateCombatGauge);
restoreMethod('showCombatImpact', originalShowCombatImpact);
restoreMethod('playCombatActionMotion', originalPlayCombatActionMotion);
}
};
const runScenario = async ({ mode, forcedRoll, present, naturalRoll = false, partnerClassKey, battleSpeed }) => {
const { attacker, partner, target, preview } = prepareScenario(mode, partnerClassKey);
if (battleSpeed) {
scene.battleSpeed = battleSpeed;
scene.fastForwardHeld = false;
scene.renderBattleSpeedControl();
}
setDebugForceHit(mode !== 'miss');
const rollCalls = [];
const chainRollCalls = [];
const originalBondChainRollSucceeded = scene.bondChainRollSucceeded;
const hadOwnBondChainRollSucceeded = Object.prototype.hasOwnProperty.call(scene, 'bondChainRollSucceeded');
scene.rollPercent = (rate) => {
rollCalls.push(rate);
return naturalRoll && rate === preview.bondChainRate;
};
scene.bondChainRollSucceeded = function (rate, forcedResult) {
const rollCountBefore = rollCalls.length;
const succeeded = originalBondChainRollSucceeded.call(this, rate, forcedResult);
chainRollCalls.push({
rate,
forcedResult: forcedResult ?? null,
rollPercentCalls: rollCalls.length - rollCountBefore,
succeeded
});
return succeeded;
};
const statsBefore = clone(scene.serializeBattleStats());
try {
const result = scene.resolveCombatAction(attacker, target, 'attack', false, undefined, forcedRoll);
const resolutionChainRollCalls = clone(chainRollCalls);
const statsAfter = clone(scene.serializeBattleStats());
const outcomeChip = clone(scene.combatOutcomeExtraChip(result));
const mapCueLabel = scene.bondChainMapCueLabel(result);
const neutralMapTexts = present ? await captureNeutralMapCue(result) : [];
let presentationDurationMs = null;
let judgement;
if (present) {
const presentationStartedAt = performance.now();
judgement = await presentCombatResult(result);
presentationDurationMs = performance.now() - presentationStartedAt;
} else {
judgement = {
presented: null,
completed: clone(scene.getDebugState().bondChainJudgement)
};
}
const formatted = scene.formatCombatResult(result);
const mapTitle = scene.combatMapDamageTitle(result);
const mapResultTexts = present ? captureMapResult(result) : [];
return {
mode,
attackerId: attacker.id,
partnerId: partner.id,
partnerName: partner.name,
partnerClassKey: partner.classKey,
battleSpeed: scene.battleSpeed,
presentationDurationMs,
targetId: target.id,
preview: {
damage: preview.damage,
chainRate: preview.bondChainRate,
chainDamage: preview.bondChainDamage,
chainPartnerId: preview.bondChainPartnerId ?? null
},
initialTargetHp: result.previousDefenderHp,
finalTargetHp: result.defender.hp,
hit: result.hit,
baseDefeated: result.baseDefeated,
defeated: result.defeated,
damage: result.damage,
attempt: clone(result.bondChainAttempt) ?? null,
chain: clone(result.bondChain) ?? null,
outcomeChip,
mapCueLabel,
neutralMapTexts,
formatted,
mapTitle,
mapResultTexts,
judgement,
chainRollCalls: resolutionChainRollCalls,
partnerStatsDelta: statsDelta(statsFor(statsBefore, partner.id), statsFor(statsAfter, partner.id)),
targetStatsDelta: statsDelta(statsFor(statsBefore, target.id), statsFor(statsAfter, target.id))
};
} finally {
if (hadOwnRollPercent) {
scene.rollPercent = originalRollPercent;
} else {
delete scene.rollPercent;
}
if (hadOwnBondChainRollSucceeded) {
scene.bondChainRollSucceeded = originalBondChainRollSucceeded;
} else {
delete scene.bondChainRollSucceeded;
}
}
};
const runProductionScenario = async (shouldDefeat) => {
const { attacker, target, preview } = prepareScenario('success');
if (!shouldDefeat) {
target.hp += Math.max(20, preview.bondChainDamage);
}
const targetView = scene.unitViews.get(target.id);
const targetMiniMapDot = scene.miniMapUnitDots.get(target.id);
if (!targetView) {
throw new Error(`Missing production finisher target view: ${target.id}`);
}
const viewState = () => ({
spriteVisible: targetView.sprite.visible,
spriteAlpha: targetView.sprite.alpha,
labelVisible: targetView.label.visible,
labelAlpha: targetView.label.alpha,
hitZoneVisible: targetView.hitZone.visible,
miniMapDotVisible: targetMiniMapDot?.visible ?? null
});
const originalForceBondChain = new URL(originalUrl).searchParams.get('debugForceBondChain');
const originalResolveCombatAction = scene.resolveCombatAction;
const originalPlayCombatCutIn = scene.playCombatCutIn;
const originalApplyDefeatedStyle = scene.applyDefeatedStyle;
const originalResolveCounterAttack = scene.resolveCounterAttack;
const methodOwnership = {
resolveCombatAction: Object.prototype.hasOwnProperty.call(scene, 'resolveCombatAction'),
playCombatCutIn: Object.prototype.hasOwnProperty.call(scene, 'playCombatCutIn'),
applyDefeatedStyle: Object.prototype.hasOwnProperty.call(scene, 'applyDefeatedStyle'),
resolveCounterAttack: Object.prototype.hasOwnProperty.call(scene, 'resolveCounterAttack')
};
let primaryResult = null;
let counterResult = null;
let visibleAfterResolve = null;
let visibleAtCutInStart = null;
let visibleAtCutInEnd = null;
let primaryCutInStarted = false;
let primaryCutInCompleted = false;
let counterCalls = 0;
let counterProduced = false;
const cutInOrder = [];
const defeatApplications = [];
const restoreMethod = (name, original) => {
if (methodOwnership[name]) {
scene[name] = original;
} else {
delete scene[name];
}
};
setDebugForceHit(true);
setDebugForceBondChain('1');
scene.rollPercent = () => false;
scene.resolveCombatAction = function (...args) {
const result = originalResolveCombatAction.apply(this, args);
if (args[3] === true) {
counterResult = result;
} else {
primaryResult = result;
visibleAfterResolve = viewState();
}
return result;
};
scene.playCombatCutIn = async function (result) {
const isPrimary = result === primaryResult;
cutInOrder.push(isPrimary ? 'primary' : result === counterResult || result.isCounter ? 'counter' : 'other');
if (isPrimary) {
primaryCutInStarted = true;
visibleAtCutInStart = viewState();
}
await originalPlayCombatCutIn.call(this, result);
if (isPrimary) {
primaryCutInCompleted = true;
visibleAtCutInEnd = viewState();
}
};
scene.applyDefeatedStyle = function (unit, view) {
if (unit.id === target.id) {
defeatApplications.push({
primaryCutInStarted,
primaryCutInCompleted,
before: viewState()
});
}
return originalApplyDefeatedStyle.call(this, unit, view);
};
scene.resolveCounterAttack = function (result) {
counterCalls += 1;
const counter = originalResolveCounterAttack.call(this, result);
counterProduced = counterProduced || Boolean(counter);
return counter;
};
try {
scene.phase = 'targeting';
scene.selectedUnit = attacker;
await scene.tryResolveDamageTarget(attacker, target, 'attack');
return {
result: primaryResult
? {
hit: primaryResult.hit,
baseDefeated: primaryResult.baseDefeated,
defeated: primaryResult.defeated,
chainDefeated: primaryResult.bondChain?.defeated ?? false,
counter: Boolean(primaryResult.counter)
}
: null,
visibleAfterResolve,
visibleAtCutInStart,
visibleAtCutInEnd,
defeatApplications,
visibleAfterWrapper: viewState(),
counterCalls,
counterProduced,
cutInOrder
};
} finally {
restoreMethod('resolveCombatAction', originalResolveCombatAction);
restoreMethod('playCombatCutIn', originalPlayCombatCutIn);
restoreMethod('applyDefeatedStyle', originalApplyDefeatedStyle);
restoreMethod('resolveCounterAttack', originalResolveCounterAttack);
if (hadOwnRollPercent) {
scene.rollPercent = originalRollPercent;
} else {
delete scene.rollPercent;
}
setDebugForceBondChain(originalForceBondChain);
}
};
let scenarios;
let restored;
try {
const productionFinisher = await runProductionScenario(true);
const productionSurvivor = await runProductionScenario(false);
const success = await runScenario({ mode: 'success', forcedRoll: true, present: true });
const failure = await runScenario({ mode: 'failure', forcedRoll: false, present: true });
const rangedNormal = await runScenario({
mode: 'success',
forcedRoll: true,
present: true,
partnerClassKey: 'archer',
battleSpeed: 'normal'
});
const rangedFast = await runScenario({
mode: 'success',
forcedRoll: true,
present: true,
partnerClassKey: 'strategist',
battleSpeed: 'fast'
});
const rangedVeryFast = await runScenario({
mode: 'success',
forcedRoll: true,
present: true,
partnerClassKey: 'strategist',
battleSpeed: 'very-fast'
});
const natural = await runScenario({ mode: 'success', forcedRoll: undefined, present: false, naturalRoll: true });
const naturalFailure = await runScenario({ mode: 'failure', forcedRoll: undefined, present: false });
const miss = await runScenario({ mode: 'miss', forcedRoll: true, present: false });
const lethal = await runScenario({ mode: 'lethal', forcedRoll: true, present: false });
scenarios = {
productionFinisher,
productionSurvivor,
success,
failure,
rangedNormal,
rangedFast,
rangedVeryFast,
natural,
naturalFailure,
miss,
lethal
};
} finally {
// Let the final lethal scenario's map popup and delayed defeat styling settle
// before restoring the live battle. Otherwise those callbacks can fire after
// restoration and hide the revived fixture target in the following RC flow.
await new Promise((resolve) => window.setTimeout(resolve, 900));
restoreFixtureState();
window.history.replaceState({}, '', originalUrl);
const restoredState = scene.getDebugState();
restored = {
save: comparableSave(clone(scene.createBattleSaveState())),
camera: { x: scene.cameraTileX, y: scene.cameraTileY },
phase: restoredState.phase,
selectedUnitId: restoredState.selectedUnitId,
pendingMove: restoredState.pendingMove,
selectedUsable: restoredState.selectedUsable,
commandMenuVisible: restoredState.commandMenuVisible,
markerCount: restoredState.markerCount,
judgement: clone(restoredState.bondChainJudgement),
partnerClassKey: scene.debugUnitById(partnerId)?.classKey ?? null,
battleSpeed: scene.battleSpeed,
fastForwardHeld: scene.fastForwardHeld,
combatObjectCount: scene.combatCutInObjects.length,
debugForceHit: new URLSearchParams(window.location.search).get('debugForceHit')
};
}
return {
ready: true,
sceneBounds: { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height },
expectedRate: mechanics.chain?.rate ?? null,
scenarios,
restored,
expectedRestored: {
save: comparableSave(originalSave),
camera: originalCamera,
phase: stateBefore.phase,
selectedUnitId: stateBefore.selectedUnitId,
pendingMove: stateBefore.pendingMove,
selectedUsable: stateBefore.selectedUsable,
commandMenuVisible: stateBefore.commandMenuVisible,
markerCount: stateBefore.markerCount,
judgement: originalJudgement ?? null,
partnerClassKey: originalPartnerClassKey ?? null,
battleSpeed: originalBattleSpeed,
fastForwardHeld: originalFastForwardHeld,
combatObjectCount: 0,
debugForceHit: new URL(originalUrl).searchParams.get('debugForceHit')
}
};
});
assert(
firstBattleBondChainJudgement.ready === true && firstBattleBondChainJudgement.expectedRate === 18,
`Expected a deterministic level-72 resonance judgement fixture: ${JSON.stringify(firstBattleBondChainJudgement)}`
);
const productionFinisher = firstBattleBondChainJudgement.scenarios.productionFinisher;
const productionViewVisible = (view) =>
view?.spriteVisible === true && view.spriteAlpha > 0.9 && view.labelVisible === true && view.labelAlpha > 0.9;
assert(
productionFinisher.result?.hit === true &&
productionFinisher.result.baseDefeated === false &&
productionFinisher.result.defeated === true &&
productionFinisher.result.chainDefeated === true &&
productionViewVisible(productionFinisher.visibleAfterResolve) &&
productionViewVisible(productionFinisher.visibleAtCutInStart) &&
productionViewVisible(productionFinisher.visibleAtCutInEnd) &&
productionFinisher.visibleAfterResolve.miniMapDotVisible === true &&
productionFinisher.visibleAtCutInStart.miniMapDotVisible === true &&
productionFinisher.visibleAtCutInEnd.miniMapDotVisible === true &&
productionFinisher.defeatApplications.length >= 1 &&
productionFinisher.defeatApplications.every((application) =>
application.primaryCutInStarted === true && application.primaryCutInCompleted === true
) &&
productionViewVisible(productionFinisher.defeatApplications[0].before) &&
productionFinisher.visibleAfterWrapper.spriteVisible === false &&
productionFinisher.visibleAfterWrapper.spriteAlpha === 0 &&
productionFinisher.visibleAfterWrapper.labelVisible === false &&
productionFinisher.visibleAfterWrapper.miniMapDotVisible === false &&
productionFinisher.counterCalls === 1 &&
productionFinisher.counterProduced === false &&
productionFinisher.result.counter === false &&
JSON.stringify(productionFinisher.cutInOrder) === JSON.stringify(['primary']),
`Expected the production attack path to keep a pursuit-defeated target visible through the finisher, then hide it and suppress counterattack: ${JSON.stringify(productionFinisher)}`
);
const productionSurvivor = firstBattleBondChainJudgement.scenarios.productionSurvivor;
assert(
productionSurvivor.result?.hit === true &&
productionSurvivor.result.baseDefeated === false &&
productionSurvivor.result.defeated === false &&
productionSurvivor.result.chainDefeated === false &&
productionSurvivor.result.counter === true &&
productionViewVisible(productionSurvivor.visibleAfterResolve) &&
productionViewVisible(productionSurvivor.visibleAtCutInStart) &&
productionViewVisible(productionSurvivor.visibleAtCutInEnd) &&
productionSurvivor.defeatApplications.length === 0 &&
productionViewVisible(productionSurvivor.visibleAfterWrapper) &&
productionSurvivor.counterCalls === 1 &&
productionSurvivor.counterProduced === true &&
JSON.stringify(productionSurvivor.cutInOrder) === JSON.stringify(['primary', 'counter']),
`Expected the production nonlethal pursuit path to preserve the defender and continue into its counterattack cut-in: ${JSON.stringify(productionSurvivor)}`
);
const judgementSuccess = firstBattleBondChainJudgement.scenarios.success;
const successAttempt = judgementSuccess.attempt;
const successChain = judgementSuccess.chain;
const successPresented = judgementSuccess.judgement.presented;
const successCompleted = judgementSuccess.judgement.completed;
const expectedSuccessMotion = judgementSuccess.partnerClassKey === 'archer' ? 'ranged' : 'melee';
const neutralSuccessCue = [judgementSuccess.outcomeChip?.label, judgementSuccess.mapCueLabel, ...judgementSuccess.neutralMapTexts]
.filter(Boolean)
.join(' · ');
assert(
judgementSuccess.hit === true &&
judgementSuccess.baseDefeated === false &&
judgementSuccess.defeated === true &&
successAttempt?.succeeded === true &&
successAttempt.partnerId === judgementSuccess.partnerId &&
successAttempt.partnerName === judgementSuccess.partnerName &&
successAttempt.rate === 18 &&
successAttempt.expectedDamage === judgementSuccess.preview.chainDamage &&
successChain?.partnerId === successAttempt.partnerId &&
successChain.partnerName === successAttempt.partnerName &&
successChain.rate === successAttempt.rate &&
successChain.damage === successAttempt.expectedDamage &&
successChain.previousDefenderHp === successChain.damage &&
successChain.defeated === true &&
judgementSuccess.initialTargetHp === judgementSuccess.damage + successChain.damage &&
judgementSuccess.finalTargetHp === 0,
`Expected forced pursuit success to preserve the attempt, full supporter damage, and follow-up defeat contract: ${JSON.stringify(judgementSuccess)}`
);
assert(
judgementSuccess.partnerStatsDelta.damageDealt === successChain.damage &&
judgementSuccess.partnerStatsDelta.defeats === 1 &&
judgementSuccess.partnerStatsDelta.actions === 0 &&
judgementSuccess.targetStatsDelta.damageTaken === judgementSuccess.damage + successChain.damage,
`Expected successful pursuit damage and defeat credit to belong only to the supporter while the defender receives both hits: ${JSON.stringify(judgementSuccess)}`
);
assert(
judgementSuccess.outcomeChip?.label === '판정 18%' &&
judgementSuccess.mapCueLabel === `공명 판정 · ${judgementSuccess.partnerName} 18%` &&
judgementSuccess.neutralMapTexts.some((text) => text.includes(judgementSuccess.mapCueLabel)) &&
!neutralSuccessCue.includes('추격') &&
!neutralSuccessCue.includes('격파') &&
!neutralSuccessCue.includes('호흡 일치') &&
!neutralSuccessCue.includes('호흡 불발'),
`Expected successful pursuit to remain a neutral percentage judgement in the outcome chip and pre-contact map cue: ${JSON.stringify(judgementSuccess)}`
);
assert(
successPresented?.status === 'success' &&
successPresented.partnerId === judgementSuccess.partnerId &&
successPresented.partnerName === judgementSuccess.partnerName &&
successPresented.rate === 18 &&
successPresented.expectedDamage === successChain.damage &&
successPresented.damage === successChain.damage &&
successPresented.defeated === true &&
successPresented.presented === true &&
successPresented.completed === false &&
successPresented.motion === expectedSuccessMotion &&
successPresented.title === '공명 판정 18%' &&
successPresented.detail === `${judgementSuccess.partnerName} · 호흡 일치` &&
isFiniteBounds(successPresented.bounds) &&
boundsInside(successPresented.bounds, firstBattleBondChainJudgement.sceneBounds),
`Expected the successful judgement to expose the bounded percentage verdict before revealing pursuit contact: ${JSON.stringify(judgementSuccess.judgement)}`
);
assert(
successCompleted?.status === 'success' &&
successCompleted.presented === true &&
successCompleted.completed === true &&
successCompleted.motion === expectedSuccessMotion &&
successCompleted.damage === successChain.damage &&
successCompleted.defeated === true &&
successCompleted.title.includes('공명 격파') &&
isFiniteBounds(successCompleted.bounds) &&
boundsInside(successCompleted.bounds, firstBattleBondChainJudgement.sceneBounds) &&
judgementSuccess.formatted.includes(`공명 판정 · ${judgementSuccess.partnerName} 18% · 호흡 일치`) &&
judgementSuccess.formatted.includes(`추격 피해 +${successChain.damage} · 공명 격파`) &&
judgementSuccess.mapTitle.includes('공명 격파') &&
judgementSuccess.mapResultTexts.some((text) => text.includes('공명 격파')),
`Expected successful pursuit contact to retain its completed debug snapshot and explicit finisher result feedback: ${JSON.stringify(judgementSuccess)}`
);
const rangedScenarios = [
firstBattleBondChainJudgement.scenarios.rangedNormal,
firstBattleBondChainJudgement.scenarios.rangedFast,
firstBattleBondChainJudgement.scenarios.rangedVeryFast
];
const rangedTelemetryIsSynchronized = (scenario) => {
const gauge = scenario.judgement.telemetry?.gauge?.[0];
const impact = scenario.judgement.telemetry?.impact?.[0];
const motion = scenario.judgement.telemetry?.partnerMotion?.[0];
return scenario.judgement.telemetry?.gauge?.length === 1 &&
scenario.judgement.telemetry?.impact?.length === 1 &&
scenario.judgement.telemetry?.partnerMotion?.length === 1 &&
gauge.duration === 300 &&
Math.abs(gauge.startScale - gauge.from) < 0.001 &&
Math.abs(gauge.endScale - gauge.to) < 0.001 &&
gauge.endAt > gauge.startAt &&
impact.damage === scenario.chain.damage &&
Math.abs(impact.at - gauge.startAt) < 80 &&
motion.startAt <= gauge.startAt &&
motion.endAt + 80 >= gauge.startAt;
};
const rangedGaugeDurations = rangedScenarios.map((scenario) => {
const gauge = scenario.judgement.telemetry.gauge[0];
return gauge.endAt - gauge.startAt;
});
assert(
rangedScenarios.map((scenario) => scenario.battleSpeed).join(',') === 'normal,fast,very-fast' &&
rangedScenarios[0].partnerClassKey === 'archer' &&
rangedScenarios[1].partnerClassKey === 'strategist' &&
rangedScenarios[2].partnerClassKey === 'strategist' &&
rangedScenarios.every((scenario) =>
scenario.attempt?.succeeded === true &&
scenario.judgement.presented?.status === 'success' &&
scenario.judgement.presented.motion === 'ranged' &&
scenario.judgement.presented.title === '공명 판정 18%' &&
scenario.judgement.completed?.completed === true &&
scenario.judgement.completed.motion === 'ranged' &&
rangedTelemetryIsSynchronized(scenario) &&
isFiniteBounds(scenario.judgement.completed.bounds) &&
boundsInside(scenario.judgement.completed.bounds, firstBattleBondChainJudgement.sceneBounds) &&
Number.isFinite(scenario.presentationDurationMs) &&
scenario.presentationDurationMs > 0
) &&
rangedScenarios[0].presentationDurationMs > rangedScenarios[1].presentationDurationMs &&
rangedScenarios[1].presentationDurationMs > rangedScenarios[2].presentationDurationMs &&
rangedGaugeDurations[0] > rangedGaugeDurations[1] &&
rangedGaugeDurations[1] > rangedGaugeDurations[2],
`Expected archer and extended-range pursuit motions to complete in all three battle speeds with ordered scaled timing: ${JSON.stringify(rangedScenarios)}`
);
const judgementNatural = firstBattleBondChainJudgement.scenarios.natural;
assert(
judgementNatural.hit === true &&
judgementNatural.baseDefeated === false &&
judgementNatural.attempt?.succeeded === true &&
judgementNatural.chain?.damage === judgementNatural.preview.chainDamage &&
judgementNatural.chainRollCalls.length === 1 &&
judgementNatural.chainRollCalls[0].rate === judgementNatural.preview.chainRate &&
judgementNatural.chainRollCalls[0].forcedResult === null &&
judgementNatural.chainRollCalls[0].rollPercentCalls === 1 &&
judgementNatural.chainRollCalls[0].succeeded === true,
`Expected the natural pursuit path to make exactly one unforced percentage roll after the base hit: ${JSON.stringify(judgementNatural)}`
);
const judgementNaturalFailure = firstBattleBondChainJudgement.scenarios.naturalFailure;
assert(
judgementNaturalFailure.hit === true &&
judgementNaturalFailure.baseDefeated === false &&
judgementNaturalFailure.attempt?.succeeded === false &&
judgementNaturalFailure.chain === null &&
judgementNaturalFailure.chainRollCalls.length === 1 &&
judgementNaturalFailure.chainRollCalls[0].rate === judgementNaturalFailure.preview.chainRate &&
judgementNaturalFailure.chainRollCalls[0].forcedResult === null &&
judgementNaturalFailure.chainRollCalls[0].rollPercentCalls === 1 &&
judgementNaturalFailure.chainRollCalls[0].succeeded === false,
`Expected the natural failed pursuit path to consume one unforced roll without applying follow-up damage: ${JSON.stringify(judgementNaturalFailure)}`
);
const judgementFailure = firstBattleBondChainJudgement.scenarios.failure;
const failureAttempt = judgementFailure.attempt;
const failurePresented = judgementFailure.judgement.presented;
const failureCompleted = judgementFailure.judgement.completed;
const expectedFailureMotion = judgementFailure.partnerClassKey === 'archer' ? 'ranged' : 'melee';
const failureLine = `공명 판정 · ${judgementFailure.partnerName} 18% · 호흡 불발`;
const neutralFailureCue = [judgementFailure.outcomeChip?.label, judgementFailure.mapCueLabel, ...judgementFailure.neutralMapTexts]
.filter(Boolean)
.join(' · ');
assert(
judgementFailure.hit === true &&
judgementFailure.baseDefeated === false &&
judgementFailure.defeated === false &&
failureAttempt?.succeeded === false &&
failureAttempt.partnerId === judgementFailure.partnerId &&
failureAttempt.partnerName === judgementFailure.partnerName &&
failureAttempt.rate === 18 &&
failureAttempt.expectedDamage === judgementFailure.preview.chainDamage &&
judgementFailure.chain === null &&
judgementFailure.finalTargetHp === judgementFailure.initialTargetHp - judgementFailure.damage,
`Expected forced pursuit failure to retain the attempted partner and rate without applying follow-up damage: ${JSON.stringify(judgementFailure)}`
);
assert(
judgementFailure.partnerStatsDelta.damageDealt === 0 &&
judgementFailure.partnerStatsDelta.defeats === 0 &&
judgementFailure.partnerStatsDelta.actions === 0 &&
judgementFailure.targetStatsDelta.damageTaken === judgementFailure.damage,
`Expected failed pursuit to leave supporter and defender pursuit statistics untouched: ${JSON.stringify(judgementFailure)}`
);
assert(
judgementFailure.outcomeChip?.label === '판정 18%' &&
judgementFailure.mapCueLabel === `공명 판정 · ${judgementFailure.partnerName} 18%` &&
judgementFailure.neutralMapTexts.some((text) => text.includes(judgementFailure.mapCueLabel)) &&
!neutralFailureCue.includes('추격') &&
!neutralFailureCue.includes('격파') &&
!neutralFailureCue.includes('호흡 불발') &&
judgementFailure.formatted.includes(failureLine) &&
judgementFailure.mapResultTexts.some((text) => text.includes(failureLine)),
`Expected failed pursuit to keep the pre-contact cue neutral and then name the partner, rate, and exact failure reason: ${JSON.stringify(judgementFailure)}`
);
assert(
failurePresented?.status === 'failed' &&
failurePresented.partnerId === judgementFailure.partnerId &&
failurePresented.partnerName === judgementFailure.partnerName &&
failurePresented.rate === 18 &&
failurePresented.expectedDamage === judgementFailure.preview.chainDamage &&
failurePresented.damage === 0 &&
failurePresented.defeated === false &&
failurePresented.presented === true &&
failurePresented.completed === false &&
failurePresented.motion === expectedFailureMotion &&
`${failurePresented.title} ${failurePresented.detail}`.includes('호흡 불발') &&
isFiniteBounds(failurePresented.bounds) &&
boundsInside(failurePresented.bounds, firstBattleBondChainJudgement.sceneBounds),
`Expected the failed judgement to expose one bounded in-progress non-damaging presentation: ${JSON.stringify(judgementFailure.judgement)}`
);
assert(
failureCompleted?.status === 'failed' &&
failureCompleted.presented === true &&
failureCompleted.completed === true &&
failureCompleted.motion === expectedFailureMotion &&
failureCompleted.damage === 0 &&
failureCompleted.defeated === false &&
`${failureCompleted.title} ${failureCompleted.detail}`.includes('호흡 불발') &&
isFiniteBounds(failureCompleted.bounds) &&
boundsInside(failureCompleted.bounds, firstBattleBondChainJudgement.sceneBounds),
`Expected failed pursuit presentation to complete once while retaining its judgement payload: ${JSON.stringify(judgementFailure.judgement)}`
);
const judgementMiss = firstBattleBondChainJudgement.scenarios.miss;
const judgementLethal = firstBattleBondChainJudgement.scenarios.lethal;
assert(
judgementMiss.hit === false &&
judgementMiss.damage === 0 &&
judgementMiss.attempt === null &&
judgementMiss.chain === null &&
judgementMiss.finalTargetHp === judgementMiss.initialTargetHp &&
judgementMiss.judgement.completed === null,
`Expected a missed base attack to skip resonance judgement entirely: ${JSON.stringify(judgementMiss)}`
);
assert(
judgementLethal.hit === true &&
judgementLethal.baseDefeated === true &&
judgementLethal.defeated === true &&
judgementLethal.attempt === null &&
judgementLethal.chain === null &&
judgementLethal.finalTargetHp === 0 &&
judgementLethal.judgement.completed === null,
`Expected a lethal base attack to skip resonance judgement entirely: ${JSON.stringify(judgementLethal)}`
);
assert(
sameJsonValue(firstBattleBondChainJudgement.restored, firstBattleBondChainJudgement.expectedRestored),
`Expected the judgement fixture to restore the full saveable battle state, camera, URL flags, selection, markers, cut-in objects, and prior debug snapshot: ${JSON.stringify(firstBattleBondChainJudgement)}`
);
const firstBattleOrderHud = await assertLiveSortieOrderHud(page, {
battleId: 'first-battle-zhuo-commandery',
orderId: 'elite',