feat: reveal resonance pursuit outcomes
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user