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)}` `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, { const firstBattleOrderHud = await assertLiveSortieOrderHud(page, {
battleId: 'first-battle-zhuo-commandery', battleId: 'first-battle-zhuo-commandery',
orderId: 'elite', orderId: 'elite',

View File

@@ -1301,6 +1301,32 @@ type BondChainResult = {
defeated: boolean; defeated: boolean;
}; };
type BondChainAttempt = {
bondId: string;
label: string;
partnerId: string;
partnerName: string;
rate: number;
expectedDamage: number;
succeeded: boolean;
};
type BondChainJudgementSnapshot = {
status: 'success' | 'failed';
partnerId: string;
partnerName: string;
rate: number;
expectedDamage: number;
damage: number;
defeated: boolean;
title: string;
detail: string;
presented: boolean;
completed: boolean;
motion: 'melee' | 'ranged';
bounds: SortieOrderHudBounds | null;
};
type UnitBattleStats = { type UnitBattleStats = {
damageDealt: number; damageDealt: number;
damageTaken: number; damageTaken: number;
@@ -1544,6 +1570,7 @@ type CombatResult = CombatPreview & {
initiativeBonusDamageAmount: number; initiativeBonusDamageAmount: number;
initiativePreventedDamageAmount: number; initiativePreventedDamageAmount: number;
initiativeReadied: boolean; initiativeReadied: boolean;
bondChainAttempt?: BondChainAttempt;
bondChain?: BondChainResult; bondChain?: BondChainResult;
counter?: CombatResult; counter?: CombatResult;
}; };
@@ -3302,6 +3329,7 @@ export class BattleScene extends Phaser.Scene {
private bondChainReadyMarkerViews: BondChainReadyMarkerView[] = []; private bondChainReadyMarkerViews: BondChainReadyMarkerView[] = [];
private bondChainReadyPartnerMarkers = new Map<string, Phaser.GameObjects.Rectangle>(); private bondChainReadyPartnerMarkers = new Map<string, Phaser.GameObjects.Rectangle>();
private bondChainReadyFocusedPreview?: BondChainReadyFocusedPreview; private bondChainReadyFocusedPreview?: BondChainReadyFocusedPreview;
private bondChainJudgementLast?: BondChainJudgementSnapshot;
private turnText?: Phaser.GameObjects.Text; private turnText?: Phaser.GameObjects.Text;
private objectiveTrackerText?: Phaser.GameObjects.Text; private objectiveTrackerText?: Phaser.GameObjects.Text;
private objectiveTrackerSubText?: Phaser.GameObjects.Text; private objectiveTrackerSubText?: Phaser.GameObjects.Text;
@@ -3432,6 +3460,7 @@ export class BattleScene extends Phaser.Scene {
this.hideTacticalCommandCutIn(); this.hideTacticalCommandCutIn();
this.tacticalCommandCutInCount = 0; this.tacticalCommandCutInCount = 0;
this.tacticalCommandCutInLast = undefined; this.tacticalCommandCutInLast = undefined;
this.bondChainJudgementLast = undefined;
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.hideTacticalCommandCutIn()); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.hideTacticalCommandCutIn());
this.resultFormationReviewVisible = false; this.resultFormationReviewVisible = false;
this.resultFormationReviewFeedback = ''; this.resultFormationReviewFeedback = '';
@@ -10135,6 +10164,10 @@ export class BattleScene extends Phaser.Scene {
this.clearMarkers(); this.clearMarkers();
await this.showBondMapEffect(result); await this.showBondMapEffect(result);
await this.playCombatCutIn(result); await this.playCombatCutIn(result);
if (result.bondChain?.defeated) {
this.applyDefeatedStyle(result.defender);
this.updateMiniMap();
}
result.counter = this.resolveCounterAttack(result); result.counter = this.resolveCounterAttack(result);
if (result.counter) { if (result.counter) {
await this.delay(180); await this.delay(180);
@@ -13632,7 +13665,11 @@ export class BattleScene extends Phaser.Scene {
return forcedRoll ?? this.rollPercent(rate); return forcedRoll ?? this.rollPercent(rate);
} }
private resolveBondChainFollowUp(preview: CombatPreview, hit: boolean): BondChainResult | undefined { private resolveBondChainFollowUp(
preview: CombatPreview,
hit: boolean,
forcedRoll = this.debugBondChainRollOverride()
): { attempt: BondChainAttempt; result?: BondChainResult } | undefined {
if (!this.canResolveBondChain(preview, hit, preview.defender.hp)) { if (!this.canResolveBondChain(preview, hit, preview.defender.hp)) {
return undefined; return undefined;
} }
@@ -13641,8 +13678,19 @@ export class BattleScene extends Phaser.Scene {
if (!partner) { if (!partner) {
return undefined; return undefined;
} }
if (!this.bondChainRollSucceeded(preview.bondChainRate)) {
return undefined; const succeeded = this.bondChainRollSucceeded(preview.bondChainRate, forcedRoll);
const attempt: BondChainAttempt = {
bondId: preview.bondChainBondId ?? '',
label: preview.bondChainLabel ?? '공명 연계',
partnerId: partner.id,
partnerName: partner.name,
rate: preview.bondChainRate,
expectedDamage: preview.bondChainDamage,
succeeded
};
if (!succeeded) {
return { attempt };
} }
const previousDefenderHp = preview.defender.hp; const previousDefenderHp = preview.defender.hp;
@@ -13653,18 +13701,31 @@ export class BattleScene extends Phaser.Scene {
this.faceUnitToward(partner, preview.defender); this.faceUnitToward(partner, preview.defender);
return { return {
bondId: preview.bondChainBondId ?? '', attempt,
label: preview.bondChainLabel ?? '공명 연계', result: {
partnerId: partner.id, bondId: attempt.bondId,
partnerName: partner.name, label: attempt.label,
rate: preview.bondChainRate, partnerId: partner.id,
damage, partnerName: partner.name,
previousDefenderHp, rate: preview.bondChainRate,
defeated damage,
previousDefenderHp,
defeated
}
}; };
} }
private resolveCombatAction(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false, usable?: BattleUsable): CombatResult { private resolveCombatAction(
attacker: UnitData,
defender: UnitData,
action: DamageCommand,
isCounter = false,
usable?: BattleUsable,
forcedBondChainRoll?: boolean
): CombatResult {
if (!isCounter) {
this.bondChainJudgementLast = undefined;
}
const preview = this.combatPreview(attacker, defender, action, usable, isCounter); const preview = this.combatPreview(attacker, defender, action, usable, isCounter);
const sortieOnlyPreview = this.combatPreview(attacker, defender, action, usable, isCounter, false, true); const sortieOnlyPreview = this.combatPreview(attacker, defender, action, usable, isCounter, false, true);
const baselinePreview = this.combatPreview(attacker, defender, action, usable, isCounter, true, true); const baselinePreview = this.combatPreview(attacker, defender, action, usable, isCounter, true, true);
@@ -13716,7 +13777,9 @@ export class BattleScene extends Phaser.Scene {
initiativeBonusDamageAmount, initiativeBonusDamageAmount,
initiativePreventedDamageAmount initiativePreventedDamageAmount
); );
const bondChain = !isCounter ? this.resolveBondChainFollowUp(preview, hit) : undefined; const bondChainResolution = !isCounter ? this.resolveBondChainFollowUp(preview, hit, forcedBondChainRoll) : undefined;
const bondChainAttempt = bondChainResolution?.attempt;
const bondChain = bondChainResolution?.result;
this.faceUnitToward(attacker, defender); this.faceUnitToward(attacker, defender);
if (hit) { if (hit) {
this.flashDamage(defender, damage, critical, previousDefenderHp, bondChain?.previousDefenderHp); this.flashDamage(defender, damage, critical, previousDefenderHp, bondChain?.previousDefenderHp);
@@ -13728,11 +13791,17 @@ export class BattleScene extends Phaser.Scene {
this.attackIntents = this.attackIntents.filter( this.attackIntents = this.attackIntents.filter(
(intent) => intent.targetId !== defender.id && intent.attackerId !== defender.id (intent) => intent.targetId !== defender.id && intent.attackerId !== defender.id
); );
this.applyDefeatedStyle(defender); if (!bondChain?.defeated) {
this.applyDefeatedStyle(defender);
} else {
this.syncUnitMotion(defender);
}
} else { } else {
this.syncUnitMotion(defender); this.syncUnitMotion(defender);
} }
this.updateMiniMap(); if (!bondChain?.defeated) {
this.updateMiniMap();
}
return { return {
...preview, ...preview,
@@ -13755,6 +13824,7 @@ export class BattleScene extends Phaser.Scene {
initiativeBonusDamageAmount, initiativeBonusDamageAmount,
initiativePreventedDamageAmount, initiativePreventedDamageAmount,
initiativeReadied, initiativeReadied,
bondChainAttempt,
bondChain bondChain
}; };
} }
@@ -14037,8 +14107,10 @@ export class BattleScene extends Phaser.Scene {
const summaryInitiative = initiativeContribution ? `\n전술 명령: ${initiativeContribution}` : ''; const summaryInitiative = initiativeContribution ? `\n전술 명령: ${initiativeContribution}` : '';
const summaryEquipment = result.equipmentEffectLabels.length > 0 ? `\n장비 효과: ${result.equipmentEffectLabels.join(', ')}` : ''; const summaryEquipment = result.equipmentEffectLabels.length > 0 ? `\n장비 효과: ${result.equipmentEffectLabels.join(', ')}` : '';
const summaryBond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : ''; const summaryBond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : '';
const summaryBondChain = result.bondChain const summaryBondChain = result.bondChainAttempt
? `\n공명 연계 · ${result.bondChain.partnerName} 추격 피해 +${result.bondChain.damage}${result.bondChain.defeated ? ' · 퇴각' : ''}` ? result.bondChain
? `\n공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 일치\n공명 연계 · ${result.bondChain.partnerName} 추격 피해 +${result.bondChain.damage}${result.bondChain.defeated ? ' · 공명 격파' : ''}`
: `\n공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 불발`
: ''; : '';
const summaryCounter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : ''; const summaryCounter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : '';
return `${headline}\n${resolution}${summaryBondChain}${summaryCounter}${summaryBondBonus}${summarySortie}${summaryInitiative}${summaryEquipment}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${summaryBond}`; return `${headline}\n${resolution}${summaryBondChain}${summaryCounter}${summaryBondBonus}${summarySortie}${summaryInitiative}${summaryEquipment}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${summaryBond}`;
@@ -14069,6 +14141,16 @@ export class BattleScene extends Phaser.Scene {
return result.hit ? result.damage : 0; return result.hit ? result.damage : 0;
} }
private bondChainMapCueLabel(result: CombatResult) {
if (result.bondChainAttempt) {
return `공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}%`;
}
if (result.bondExtendedRange) {
return '삼재진 · 2칸 공명';
}
return '공명 강화';
}
private combatResultSummaryLine(result: CombatResult) { private combatResultSummaryLine(result: CombatResult) {
const hpLine = result.hit ? ` · HP ${result.previousDefenderHp}${result.defender.hp}` : ''; const hpLine = result.hit ? ` · HP ${result.previousDefenderHp}${result.defender.hp}` : '';
const counter = !result.isCounter && result.counter ? ' · 반격 발생' : ''; const counter = !result.isCounter && result.counter ? ' · 반격 발생' : '';
@@ -15452,6 +15534,7 @@ export class BattleScene extends Phaser.Scene {
this.lockedTargetPreview = undefined; this.lockedTargetPreview = undefined;
this.battleOutcome = undefined; this.battleOutcome = undefined;
this.resultSortieOrder = undefined; this.resultSortieOrder = undefined;
this.bondChainJudgementLast = undefined;
this.phase = 'idle'; this.phase = 'idle';
this.launchSortieOrderId = this.normalizeLaunchSortieOrderId(state.sortieOrderId ?? this.launchSortieOrderId); this.launchSortieOrderId = this.normalizeLaunchSortieOrderId(state.sortieOrderId ?? this.launchSortieOrderId);
@@ -16427,7 +16510,12 @@ export class BattleScene extends Phaser.Scene {
); );
defenderStatus.hpText.setText(`${hpAfterBaseAttack} / ${result.defender.maxHp}`); defenderStatus.hpText.setText(`${hpAfterBaseAttack} / ${result.defender.maxHp}`);
if (result.bondChainAttempt) {
await this.showBondChainJudgementEffect(result, left + panelWidth / 2, top + 142, depth + 19);
}
if (result.bondChain) { if (result.bondChain) {
let chainGaugePromise: Promise<void> | undefined;
await this.showBondChainCombatEffect( await this.showBondChainCombatEffect(
result, result,
defenderSprite, defenderSprite,
@@ -16435,15 +16523,22 @@ export class BattleScene extends Phaser.Scene {
groundY, groundY,
left + panelWidth / 2, left + panelWidth / 2,
top + 142, top + 142,
depth + 19 depth + 19,
() => {
chainGaugePromise = this.animateCombatGauge(
defenderStatus.hpFill,
result.bondChain!.previousDefenderHp / result.defender.maxHp,
result.defender.hp / result.defender.maxHp,
300
).then(() => {
defenderStatus.hpText.setText(`${result.defender.hp} / ${result.defender.maxHp}`);
});
}
); );
await this.animateCombatGauge( await chainGaugePromise;
defenderStatus.hpFill, }
result.bondChain.previousDefenderHp / result.defender.maxHp, if (result.bondChainAttempt && this.bondChainJudgementLast) {
result.defender.hp / result.defender.maxHp, this.bondChainJudgementLast.completed = true;
300
);
defenderStatus.hpText.setText(`${result.defender.hp} / ${result.defender.maxHp}`);
} }
await this.animateGrowthGauge( await this.animateGrowthGauge(
@@ -16596,8 +16691,8 @@ export class BattleScene extends Phaser.Scene {
if (result.baseDefeated) { if (result.baseDefeated) {
return { icon: 'counter', label: '퇴각', tone: 0xd8732c }; return { icon: 'counter', label: '퇴각', tone: 0xd8732c };
} }
if (result.bondChain) { if (result.bondChainAttempt) {
return { icon: 'leadership', label: `추격 +${result.bondChain.damage}`, tone: palette.gold }; return { icon: 'leadership', label: `판정 ${result.bondChainAttempt.rate}%`, tone: palette.gold };
} }
if (this.combatCounterWillTrigger(result)) { if (this.combatCounterWillTrigger(result)) {
return { icon: 'counter', label: '반격 예정', tone: 0xb64a45 }; return { icon: 'counter', label: '반격 예정', tone: 0xb64a45 };
@@ -18308,7 +18403,7 @@ export class BattleScene extends Phaser.Scene {
return Promise.resolve(); return Promise.resolve();
} }
const effectPartnerIds = result.bondChain ? [result.bondChain.partnerId] : result.bondPartnerIds; const effectPartnerIds = result.bondChainAttempt ? [result.bondChainAttempt.partnerId] : result.bondPartnerIds;
const partnerUnits = effectPartnerIds const partnerUnits = effectPartnerIds
.map((partnerId) => battleUnits.find((unit) => unit.id === partnerId && unit.hp > 0)) .map((partnerId) => battleUnits.find((unit) => unit.id === partnerId && unit.hp > 0))
.filter((unit): unit is UnitData => Boolean(unit)); .filter((unit): unit is UnitData => Boolean(unit));
@@ -18343,22 +18438,28 @@ export class BattleScene extends Phaser.Scene {
line.lineTo(partnerView.sprite.x, partnerView.sprite.y - this.layout.tileSize * 0.28); line.lineTo(partnerView.sprite.x, partnerView.sprite.y - this.layout.tileSize * 0.28);
line.strokePath(); line.strokePath();
} }
const partnerLabel = result.bondExtendedRange ? `${partner.name} · 2칸` : `${partner.name} ♥ 공명`; const partnerLabel = result.bondChainAttempt
? `${partner.name} · 판정 후보`
: result.bondExtendedRange
? `${partner.name} · 2칸`
: `${partner.name} ♥ 공명`;
objects.push(this.createBondBadge(partnerView.sprite.x, partnerView.sprite.y - this.layout.tileSize * 0.86, partnerLabel, 35)); objects.push(this.createBondBadge(partnerView.sprite.x, partnerView.sprite.y - this.layout.tileSize * 0.86, partnerLabel, 35));
}); });
objects.push(line); objects.push(line);
objects.push(this.createBondBadge( objects.push(this.createBondBadge(
attackerView.sprite.x, attackerView.sprite.x,
attackerView.sprite.y - this.layout.tileSize * 0.96, attackerView.sprite.y - this.layout.tileSize * 0.96,
result.bondChain this.bondChainMapCueLabel(result),
? `공명 추격 · ${result.bondChain.partnerName}`
: result.bondExtendedRange
? '삼재진 · 2칸 공명'
: '공명 강화',
36 36
)); ));
this.tweens.add({ targets: objects, alpha: 0, y: '-=12', duration: 620, ease: 'Sine.easeOut' }); this.tweens.add({
targets: objects,
alpha: 0,
y: '-=12',
duration: this.scaledBattleDuration(620, 150),
ease: 'Sine.easeOut'
});
return this.delay(660).then(() => objects.forEach((object) => object.destroy())); return this.delay(660).then(() => objects.forEach((object) => object.destroy()));
} }
@@ -18424,6 +18525,95 @@ export class BattleScene extends Phaser.Scene {
return this.delay(580); return this.delay(580);
} }
private bondChainMotion(partner?: UnitData): 'melee' | 'ranged' {
return partner && (partner.classKey === 'archer' || this.attackRange(partner) > 1) ? 'ranged' : 'melee';
}
private async showBondChainJudgementEffect(result: CombatResult, textX: number, textY: number, depth: number) {
const attempt = result.bondChainAttempt;
if (!attempt) {
return;
}
const partner = battleUnits.find((unit) => unit.id === attempt.partnerId);
const succeeded = attempt.succeeded && Boolean(result.bondChain);
const status: BondChainJudgementSnapshot['status'] = succeeded ? 'success' : 'failed';
const motion = this.bondChainMotion(partner);
const width = 372;
const height = 66;
const tone = succeeded ? palette.gold : 0xa58f69;
const title = `공명 판정 ${attempt.rate}%`;
const detail = `${attempt.partnerName} · ${succeeded ? '호흡 일치' : '호흡 불발'}`;
const card = this.trackCombatObject(this.add.container(textX, textY));
card.setDepth(depth + 4);
card.setAlpha(0);
card.setScale(0.92);
const background = this.add.rectangle(0, 0, width, height, 0x11151a, 0.97);
background.setStrokeStyle(2, tone, succeeded ? 0.94 : 0.68);
const sideBand = this.add.rectangle(-width / 2 + 5, 0, 10, height, tone, succeeded ? 0.96 : 0.62);
const iconFrame = this.add.circle(-width / 2 + 39, 0, 22, 0x0a0f14, 0.96);
iconFrame.setStrokeStyle(1, tone, 0.78);
const icon = this.createBattleUiIcon(-width / 2 + 39, 0, 'leadership', 39);
const titleText = this.add.text(-width / 2 + 72, -23, title, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '20px',
color: succeeded ? '#fff2b8' : '#d8c9ac',
fontStyle: '700',
stroke: '#120d08',
strokeThickness: 3
});
const detailText = this.add.text(-width / 2 + 73, 7, detail, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: succeeded ? '#ffdf7b' : '#aeb2b5',
fontStyle: '700'
});
card.add([background, sideBand, iconFrame, icon, titleText, detailText]);
this.bondChainJudgementLast = {
status,
partnerId: attempt.partnerId,
partnerName: attempt.partnerName,
rate: attempt.rate,
expectedDamage: attempt.expectedDamage,
damage: result.bondChain?.damage ?? 0,
defeated: result.bondChain?.defeated ?? false,
title,
detail,
presented: true,
completed: false,
motion,
bounds: { x: textX - width / 2, y: textY - height / 2, width, height }
};
soundDirector.playEffect('bond-resonance', {
volume: succeeded ? 0.28 : 0.16,
rate: succeeded ? 1.08 : 0.82,
stopAfterMs: 620
});
this.tweens.add({
targets: card,
alpha: 1,
scale: 1,
y: textY - 4,
duration: this.scaledBattleDuration(160, 80),
ease: 'Back.easeOut'
});
await this.delay(succeeded ? 250 : 430);
this.tweens.add({
targets: card,
alpha: 0,
y: card.y - 8,
duration: this.scaledBattleDuration(150, 75),
ease: 'Sine.easeIn'
});
await this.delay(170);
if (card.active) {
card.destroy();
}
}
private async showBondChainCombatEffect( private async showBondChainCombatEffect(
result: CombatResult, result: CombatResult,
defenderSprite: Phaser.GameObjects.Sprite, defenderSprite: Phaser.GameObjects.Sprite,
@@ -18431,7 +18621,8 @@ export class BattleScene extends Phaser.Scene {
groundY: number, groundY: number,
textX: number, textX: number,
textY: number, textY: number,
depth: number depth: number,
onImpact?: () => void
) { ) {
const chain = result.bondChain; const chain = result.bondChain;
const partner = chain ? battleUnits.find((unit) => unit.id === chain.partnerId) : undefined; const partner = chain ? battleUnits.find((unit) => unit.id === chain.partnerId) : undefined;
@@ -18441,8 +18632,8 @@ export class BattleScene extends Phaser.Scene {
soundDirector.playEffect('bond-resonance', { volume: 0.3, rate: 1.08, stopAfterMs: 780 }); soundDirector.playEffect('bond-resonance', { volume: 0.3, rate: 1.08, stopAfterMs: 780 });
const entryX = defenderX - 230; const motion = this.bondChainMotion(partner);
const strikeX = defenderX - 82; const entryX = defenderX - (motion === 'ranged' ? 360 : 270);
const supporterSprite = this.trackCombatObject( const supporterSprite = this.trackCombatObject(
this.add.sprite( this.add.sprite(
entryX, entryX,
@@ -18456,49 +18647,162 @@ export class BattleScene extends Phaser.Scene {
this.applyCombatSpriteDisplaySize(supporterSprite, partner); this.applyCombatSpriteDisplaySize(supporterSprite, partner);
this.applyActionSpriteBlend(supporterSprite); this.applyActionSpriteBlend(supporterSprite);
const banner = this.trackCombatObject(this.add.text( const bannerWidth = 388;
textX, const bannerHeight = 68;
textY, const banner = this.trackCombatObject(this.add.container(textX, textY));
`공명 추격 ${chain.partnerName}\n추가 피해 +${chain.damage}`, banner.setDepth(depth + 3);
banner.setAlpha(0);
banner.setScale(0.94);
const bannerBg = this.add.rectangle(0, 0, bannerWidth, bannerHeight, 0x11151a, 0.98);
bannerBg.setStrokeStyle(2, palette.gold, 0.94);
const bannerBand = this.add.rectangle(-bannerWidth / 2 + 5, 0, 10, bannerHeight, palette.gold, 0.96);
const bannerIconFrame = this.add.circle(-bannerWidth / 2 + 39, 0, 22, 0x0a0f14, 0.96);
bannerIconFrame.setStrokeStyle(1, palette.gold, 0.82);
const bannerIcon = this.createBattleUiIcon(-bannerWidth / 2 + 39, 0, 'leadership', 39);
const bannerTitle = this.add.text(-bannerWidth / 2 + 72, -23, `공명 추격 · ${chain.partnerName}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '20px',
color: '#fff2b8',
fontStyle: '700',
stroke: '#2b1606',
strokeThickness: 3
});
const bannerDetail = this.add.text(-bannerWidth / 2 + 73, 7, `${motion === 'ranged' ? '원거리' : '근접'} 추격 준비`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: '#ffdf7b',
fontStyle: '700'
});
banner.add([bannerBg, bannerBand, bannerIconFrame, bannerIcon, bannerTitle, bannerDetail]);
this.tweens.add({
targets: [supporterSprite, banner],
alpha: 1,
duration: this.scaledBattleDuration(120, 70),
ease: 'Sine.easeOut'
});
this.tweens.add({
targets: banner,
scale: 1,
y: textY - 5,
duration: this.scaledBattleDuration(180, 90),
ease: 'Back.easeOut'
});
await this.delay(130);
const followUpResult: CombatResult = {
...result,
action: 'attack',
usable: undefined,
attacker: partner,
expectedDamage: chain.damage,
previousDefenderHp: chain.previousDefenderHp,
damage: chain.damage,
hit: true,
critical: false,
isCounter: false,
baseDefeated: chain.defeated,
defeated: chain.defeated,
bondChainAttempt: undefined,
bondChain: undefined,
counter: undefined
};
const contactDelay = motion === 'ranged'
? this.scaledBattleDuration(150) + this.scaledBattleDuration(332, 90)
: this.scaledBattleDuration(115) + this.scaledBattleDuration(partner.classKey === 'cavalry' ? 170 : 205);
const motionPromise = this.playCombatActionMotion(
followUpResult,
supporterSprite,
defenderSprite,
entryX,
defenderX,
groundY,
depth + 1,
'west'
);
await new Promise<void>((resolve) => this.time.delayedCall(contactDelay, resolve));
onImpact?.();
this.showCombatImpact(followUpResult, defenderX, groundY - 46, depth + 5);
const finishTitle = chain.defeated ? `공명 격파 · ${chain.partnerName}` : `공명 추격 · ${chain.partnerName}`;
const finishDetail = `추격 -${chain.damage}`;
bannerTitle.setText(finishTitle);
bannerDetail.setText(finishDetail);
if (this.bondChainJudgementLast) {
this.bondChainJudgementLast.title = finishTitle;
this.bondChainJudgementLast.detail = finishDetail;
this.bondChainJudgementLast.damage = chain.damage;
this.bondChainJudgementLast.defeated = chain.defeated;
this.bondChainJudgementLast.motion = motion;
this.bondChainJudgementLast.bounds = {
x: textX - bannerWidth / 2,
y: textY - 5 - bannerHeight / 2,
width: bannerWidth,
height: bannerHeight
};
}
const damageText = this.trackCombatObject(this.add.text(
defenderX - 12,
groundY - 112,
chain.defeated ? `공명 격파\n추격 -${chain.damage}` : `추격 -${chain.damage}`,
{ {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '20px', fontSize: chain.defeated ? '26px' : '23px',
color: '#fff2b8', color: chain.defeated ? '#fff2b8' : '#ffdf7b',
fontStyle: '700', fontStyle: '700',
align: 'center', align: 'center',
stroke: '#2b1606', stroke: chain.defeated ? '#5a180f' : '#2b1606',
strokeThickness: 5, strokeThickness: 5,
lineSpacing: 2 lineSpacing: -2
} }
)); ));
banner.setOrigin(0.5); damageText.setOrigin(0.5);
banner.setDepth(depth + 3); damageText.setDepth(depth + 6);
banner.setScale(0.88); damageText.setScale(0.82);
banner.setAlpha(0); this.tweens.add({
targets: damageText,
scale: chain.defeated ? 1.18 : 1,
y: damageText.y - 14,
alpha: 0,
duration: this.scaledBattleDuration(chain.defeated ? 620 : 480, 180),
ease: 'Back.easeOut'
});
const trail = this.trackCombatObject(this.add.graphics()); if (chain.defeated) {
trail.setDepth(depth - 1); this.cameras.main.shake(this.scaledBattleDuration(180, 110), 0.008);
trail.lineStyle(7, 0xffdf7b, 0.7); const finishRing = this.trackCombatObject(this.add.circle(defenderX - 24, groundY - 48, 24));
trail.beginPath(); finishRing.setDepth(depth + 5);
trail.moveTo(entryX, groundY - 40); finishRing.setStrokeStyle(7, 0xffdf7b, 0.94);
trail.lineTo(defenderX - 36, groundY - 40); const finishStar = this.trackCombatObject(this.add.star(defenderX - 24, groundY - 48, 10, 18, 62, 0xfff0b8, 0.96));
trail.strokePath(); finishStar.setDepth(depth + 4);
trail.setAlpha(0); this.tweens.add({
targets: finishRing,
scale: 2.2,
alpha: 0,
duration: this.scaledBattleDuration(420, 140),
ease: 'Sine.easeOut'
});
this.tweens.add({
targets: finishStar,
scale: 1.8,
angle: 110,
alpha: 0,
duration: this.scaledBattleDuration(390, 140),
ease: 'Sine.easeOut'
});
}
this.tweens.add({ targets: [supporterSprite, banner, trail], alpha: 1, duration: 120, ease: 'Sine.easeOut' }); await motionPromise;
this.tweens.add({ targets: banner, scale: 1, y: textY - 5, duration: 180, ease: 'Back.easeOut' }); this.tweens.add({
this.tweens.add({ targets: supporterSprite, x: strikeX, duration: 230, ease: 'Cubic.easeIn' }); targets: [supporterSprite, banner],
await this.delay(240); alpha: 0,
y: '-=8',
const impact = this.trackCombatObject(this.add.star(defenderX - 24, groundY - 46, 8, 18, 52, 0xfff0b8, 0.94)); duration: this.scaledBattleDuration(180, 90),
impact.setDepth(depth + 2); ease: 'Sine.easeIn'
defenderSprite.setTintFill(0xffe0a3); });
this.tweens.add({ targets: impact, scale: 1.55, angle: 90, alpha: 0, duration: 300, ease: 'Sine.easeOut' }); await this.delay(200);
this.tweens.add({ targets: defenderSprite, x: defenderX + 14, duration: 90, yoyo: true, ease: 'Sine.easeOut' });
this.tweens.add({ targets: supporterSprite, x: entryX + 30, alpha: 0, duration: 260, delay: 80, ease: 'Sine.easeIn' });
await this.delay(220);
defenderSprite.clearTint();
await this.delay(180);
} }
private showSortieAttackCombatEffect(result: CombatResult, x: number, y: number, depth: number) { private showSortieAttackCombatEffect(result: CombatResult, x: number, y: number, depth: number) {
@@ -20293,12 +20597,17 @@ export class BattleScene extends Phaser.Scene {
view.sprite.setTintFill(0xffe0a3); view.sprite.setTintFill(0xffe0a3);
this.time.delayedCall(120, () => { this.time.delayedCall(120, () => {
if (unit.hp <= 0) { if (displayedHp <= 0) {
this.applyDefeatedStyle(unit); this.applyDefeatedStyle(unit);
} else { } else {
view.sprite.clearTint(); view.sprite.clearTint();
this.syncUnitMotion(unit, view); this.syncUnitMotion(unit, view);
this.applyUnitLegibilityStyle(unit, view); // A successful resonance follow-up may already have reduced the real HP
// to zero while the map is intentionally showing the post-base-attack HP.
// Keep that defender visible until the pursuit finisher has played.
if (unit.hp > 0) {
this.applyUnitLegibilityStyle(unit, view);
}
} }
}); });
} }
@@ -20351,7 +20660,11 @@ export class BattleScene extends Phaser.Scene {
} }
const effectLine = [ const effectLine = [
result.bondChain ? `공명 추격 ${result.bondChain.partnerName} +${result.bondChain.damage}` : '', result.bondChain
? `${result.bondChain.defeated ? '공명 격파' : '공명 추격'} ${result.bondChain.partnerName} +${result.bondChain.damage}`
: result.bondChainAttempt
? `공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 불발`
: '',
this.statusEffectMapText(result.statusApplied), this.statusEffectMapText(result.statusApplied),
this.combatSortieContributionText(result), this.combatSortieContributionText(result),
this.combatInitiativeContributionText(result) this.combatInitiativeContributionText(result)
@@ -20377,7 +20690,7 @@ export class BattleScene extends Phaser.Scene {
private combatMapDamageTitle(result: CombatResult) { private combatMapDamageTitle(result: CombatResult) {
if (result.bondChain) { if (result.bondChain) {
const totalDamage = result.damage + result.bondChain.damage; const totalDamage = result.damage + result.bondChain.damage;
return result.bondChain.defeated ? `공명 퇴각 -${totalDamage}` : `합동 피해 -${totalDamage}`; return result.bondChain.defeated ? `공명 격파 -${totalDamage}` : `합동 피해 -${totalDamage}`;
} }
const damage = `-${result.damage}`; const damage = `-${result.damage}`;
if (result.baseDefeated) { if (result.baseDefeated) {
@@ -22103,6 +22416,15 @@ export class BattleScene extends Phaser.Scene {
}; };
} }
private bondChainJudgementDebugState() {
return this.bondChainJudgementLast
? {
...this.bondChainJudgementLast,
bounds: this.bondChainJudgementLast.bounds ? { ...this.bondChainJudgementLast.bounds } : null
}
: null;
}
private sortieOrderHudDebugState() { private sortieOrderHudDebugState() {
const snapshot = this.sortieOrderHudSnapshot; const snapshot = this.sortieOrderHudSnapshot;
if (!snapshot) { if (!snapshot) {
@@ -22268,6 +22590,7 @@ export class BattleScene extends Phaser.Scene {
pointerFeedbackVisible: Boolean(this.pointerFeedbackMarker?.visible), pointerFeedbackVisible: Boolean(this.pointerFeedbackMarker?.visible),
pointerFeedbackTile: this.pointerFeedbackTile ? { ...this.pointerFeedbackTile } : null, pointerFeedbackTile: this.pointerFeedbackTile ? { ...this.pointerFeedbackTile } : null,
bondChainReadyPreview: this.bondChainReadyDebugState(), bondChainReadyPreview: this.bondChainReadyDebugState(),
bondChainJudgement: this.bondChainJudgementDebugState(),
markerCount: this.markers.length, markerCount: this.markers.length,
threatMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'threat').length, threatMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'threat').length,
enemyMoveMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'enemy-move').length, enemyMoveMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'enemy-move').length,