diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 9637ea4..5732ff0 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -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', diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 1927d9c..2f7036d 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1301,6 +1301,32 @@ type BondChainResult = { 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 = { damageDealt: number; damageTaken: number; @@ -1544,6 +1570,7 @@ type CombatResult = CombatPreview & { initiativeBonusDamageAmount: number; initiativePreventedDamageAmount: number; initiativeReadied: boolean; + bondChainAttempt?: BondChainAttempt; bondChain?: BondChainResult; counter?: CombatResult; }; @@ -3302,6 +3329,7 @@ export class BattleScene extends Phaser.Scene { private bondChainReadyMarkerViews: BondChainReadyMarkerView[] = []; private bondChainReadyPartnerMarkers = new Map(); private bondChainReadyFocusedPreview?: BondChainReadyFocusedPreview; + private bondChainJudgementLast?: BondChainJudgementSnapshot; private turnText?: Phaser.GameObjects.Text; private objectiveTrackerText?: Phaser.GameObjects.Text; private objectiveTrackerSubText?: Phaser.GameObjects.Text; @@ -3432,6 +3460,7 @@ export class BattleScene extends Phaser.Scene { this.hideTacticalCommandCutIn(); this.tacticalCommandCutInCount = 0; this.tacticalCommandCutInLast = undefined; + this.bondChainJudgementLast = undefined; this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.hideTacticalCommandCutIn()); this.resultFormationReviewVisible = false; this.resultFormationReviewFeedback = ''; @@ -10135,6 +10164,10 @@ export class BattleScene extends Phaser.Scene { this.clearMarkers(); await this.showBondMapEffect(result); await this.playCombatCutIn(result); + if (result.bondChain?.defeated) { + this.applyDefeatedStyle(result.defender); + this.updateMiniMap(); + } result.counter = this.resolveCounterAttack(result); if (result.counter) { await this.delay(180); @@ -13632,7 +13665,11 @@ export class BattleScene extends Phaser.Scene { 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)) { return undefined; } @@ -13641,8 +13678,19 @@ export class BattleScene extends Phaser.Scene { if (!partner) { 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; @@ -13653,18 +13701,31 @@ export class BattleScene extends Phaser.Scene { this.faceUnitToward(partner, preview.defender); return { - bondId: preview.bondChainBondId ?? '', - label: preview.bondChainLabel ?? '공명 연계', - partnerId: partner.id, - partnerName: partner.name, - rate: preview.bondChainRate, - damage, - previousDefenderHp, - defeated + attempt, + result: { + bondId: attempt.bondId, + label: attempt.label, + partnerId: partner.id, + partnerName: partner.name, + rate: preview.bondChainRate, + 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 sortieOnlyPreview = this.combatPreview(attacker, defender, action, usable, isCounter, false, true); const baselinePreview = this.combatPreview(attacker, defender, action, usable, isCounter, true, true); @@ -13716,7 +13777,9 @@ export class BattleScene extends Phaser.Scene { initiativeBonusDamageAmount, 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); if (hit) { this.flashDamage(defender, damage, critical, previousDefenderHp, bondChain?.previousDefenderHp); @@ -13728,11 +13791,17 @@ export class BattleScene extends Phaser.Scene { this.attackIntents = this.attackIntents.filter( (intent) => intent.targetId !== defender.id && intent.attackerId !== defender.id ); - this.applyDefeatedStyle(defender); + if (!bondChain?.defeated) { + this.applyDefeatedStyle(defender); + } else { + this.syncUnitMotion(defender); + } } else { this.syncUnitMotion(defender); } - this.updateMiniMap(); + if (!bondChain?.defeated) { + this.updateMiniMap(); + } return { ...preview, @@ -13755,6 +13824,7 @@ export class BattleScene extends Phaser.Scene { initiativeBonusDamageAmount, initiativePreventedDamageAmount, initiativeReadied, + bondChainAttempt, bondChain }; } @@ -14037,8 +14107,10 @@ export class BattleScene extends Phaser.Scene { const summaryInitiative = initiativeContribution ? `\n전술 명령: ${initiativeContribution}` : ''; const summaryEquipment = result.equipmentEffectLabels.length > 0 ? `\n장비 효과: ${result.equipmentEffectLabels.join(', ')}` : ''; const summaryBond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : ''; - const summaryBondChain = result.bondChain - ? `\n공명 연계 · ${result.bondChain.partnerName} 추격 피해 +${result.bondChain.damage}${result.bondChain.defeated ? ' · 퇴각' : ''}` + const summaryBondChain = result.bondChainAttempt + ? 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)}` : ''; 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; } + private bondChainMapCueLabel(result: CombatResult) { + if (result.bondChainAttempt) { + return `공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}%`; + } + if (result.bondExtendedRange) { + return '삼재진 · 2칸 공명'; + } + return '공명 강화'; + } + private combatResultSummaryLine(result: CombatResult) { const hpLine = result.hit ? ` · HP ${result.previousDefenderHp}→${result.defender.hp}` : ''; const counter = !result.isCounter && result.counter ? ' · 반격 발생' : ''; @@ -15452,6 +15534,7 @@ export class BattleScene extends Phaser.Scene { this.lockedTargetPreview = undefined; this.battleOutcome = undefined; this.resultSortieOrder = undefined; + this.bondChainJudgementLast = undefined; this.phase = 'idle'; 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}`); + if (result.bondChainAttempt) { + await this.showBondChainJudgementEffect(result, left + panelWidth / 2, top + 142, depth + 19); + } + if (result.bondChain) { + let chainGaugePromise: Promise | undefined; await this.showBondChainCombatEffect( result, defenderSprite, @@ -16435,15 +16523,22 @@ export class BattleScene extends Phaser.Scene { groundY, left + panelWidth / 2, 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( - defenderStatus.hpFill, - result.bondChain.previousDefenderHp / result.defender.maxHp, - result.defender.hp / result.defender.maxHp, - 300 - ); - defenderStatus.hpText.setText(`${result.defender.hp} / ${result.defender.maxHp}`); + await chainGaugePromise; + } + if (result.bondChainAttempt && this.bondChainJudgementLast) { + this.bondChainJudgementLast.completed = true; } await this.animateGrowthGauge( @@ -16596,8 +16691,8 @@ export class BattleScene extends Phaser.Scene { if (result.baseDefeated) { return { icon: 'counter', label: '퇴각', tone: 0xd8732c }; } - if (result.bondChain) { - return { icon: 'leadership', label: `추격 +${result.bondChain.damage}`, tone: palette.gold }; + if (result.bondChainAttempt) { + return { icon: 'leadership', label: `판정 ${result.bondChainAttempt.rate}%`, tone: palette.gold }; } if (this.combatCounterWillTrigger(result)) { return { icon: 'counter', label: '반격 예정', tone: 0xb64a45 }; @@ -18308,7 +18403,7 @@ export class BattleScene extends Phaser.Scene { return Promise.resolve(); } - const effectPartnerIds = result.bondChain ? [result.bondChain.partnerId] : result.bondPartnerIds; + const effectPartnerIds = result.bondChainAttempt ? [result.bondChainAttempt.partnerId] : result.bondPartnerIds; const partnerUnits = effectPartnerIds .map((partnerId) => battleUnits.find((unit) => unit.id === partnerId && unit.hp > 0)) .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.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(line); objects.push(this.createBondBadge( attackerView.sprite.x, attackerView.sprite.y - this.layout.tileSize * 0.96, - result.bondChain - ? `공명 추격 · ${result.bondChain.partnerName}` - : result.bondExtendedRange - ? '삼재진 · 2칸 공명' - : '공명 강화', + this.bondChainMapCueLabel(result), 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())); } @@ -18424,6 +18525,95 @@ export class BattleScene extends Phaser.Scene { 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( result: CombatResult, defenderSprite: Phaser.GameObjects.Sprite, @@ -18431,7 +18621,8 @@ export class BattleScene extends Phaser.Scene { groundY: number, textX: number, textY: number, - depth: number + depth: number, + onImpact?: () => void ) { const chain = result.bondChain; 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 }); - const entryX = defenderX - 230; - const strikeX = defenderX - 82; + const motion = this.bondChainMotion(partner); + const entryX = defenderX - (motion === 'ranged' ? 360 : 270); const supporterSprite = this.trackCombatObject( this.add.sprite( entryX, @@ -18456,49 +18647,162 @@ export class BattleScene extends Phaser.Scene { this.applyCombatSpriteDisplaySize(supporterSprite, partner); this.applyActionSpriteBlend(supporterSprite); - const banner = this.trackCombatObject(this.add.text( - textX, - textY, - `공명 추격 ${chain.partnerName}\n추가 피해 +${chain.damage}`, + const bannerWidth = 388; + const bannerHeight = 68; + const banner = this.trackCombatObject(this.add.container(textX, textY)); + 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((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', - fontSize: '20px', - color: '#fff2b8', + fontSize: chain.defeated ? '26px' : '23px', + color: chain.defeated ? '#fff2b8' : '#ffdf7b', fontStyle: '700', align: 'center', - stroke: '#2b1606', + stroke: chain.defeated ? '#5a180f' : '#2b1606', strokeThickness: 5, - lineSpacing: 2 + lineSpacing: -2 } )); - banner.setOrigin(0.5); - banner.setDepth(depth + 3); - banner.setScale(0.88); - banner.setAlpha(0); + damageText.setOrigin(0.5); + damageText.setDepth(depth + 6); + damageText.setScale(0.82); + 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()); - trail.setDepth(depth - 1); - trail.lineStyle(7, 0xffdf7b, 0.7); - trail.beginPath(); - trail.moveTo(entryX, groundY - 40); - trail.lineTo(defenderX - 36, groundY - 40); - trail.strokePath(); - trail.setAlpha(0); + if (chain.defeated) { + this.cameras.main.shake(this.scaledBattleDuration(180, 110), 0.008); + const finishRing = this.trackCombatObject(this.add.circle(defenderX - 24, groundY - 48, 24)); + finishRing.setDepth(depth + 5); + finishRing.setStrokeStyle(7, 0xffdf7b, 0.94); + const finishStar = this.trackCombatObject(this.add.star(defenderX - 24, groundY - 48, 10, 18, 62, 0xfff0b8, 0.96)); + finishStar.setDepth(depth + 4); + 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' }); - this.tweens.add({ targets: banner, scale: 1, y: textY - 5, duration: 180, ease: 'Back.easeOut' }); - this.tweens.add({ targets: supporterSprite, x: strikeX, duration: 230, ease: 'Cubic.easeIn' }); - await this.delay(240); - - const impact = this.trackCombatObject(this.add.star(defenderX - 24, groundY - 46, 8, 18, 52, 0xfff0b8, 0.94)); - impact.setDepth(depth + 2); - defenderSprite.setTintFill(0xffe0a3); - this.tweens.add({ targets: impact, scale: 1.55, angle: 90, alpha: 0, duration: 300, ease: 'Sine.easeOut' }); - 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); + await motionPromise; + this.tweens.add({ + targets: [supporterSprite, banner], + alpha: 0, + y: '-=8', + duration: this.scaledBattleDuration(180, 90), + ease: 'Sine.easeIn' + }); + await this.delay(200); } private showSortieAttackCombatEffect(result: CombatResult, x: number, y: number, depth: number) { @@ -20293,12 +20597,17 @@ export class BattleScene extends Phaser.Scene { view.sprite.setTintFill(0xffe0a3); this.time.delayedCall(120, () => { - if (unit.hp <= 0) { + if (displayedHp <= 0) { this.applyDefeatedStyle(unit); } else { view.sprite.clearTint(); 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 = [ - 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.combatSortieContributionText(result), this.combatInitiativeContributionText(result) @@ -20377,7 +20690,7 @@ export class BattleScene extends Phaser.Scene { private combatMapDamageTitle(result: CombatResult) { if (result.bondChain) { const totalDamage = result.damage + result.bondChain.damage; - return result.bondChain.defeated ? `공명 퇴각 -${totalDamage}` : `합동 피해 -${totalDamage}`; + return result.bondChain.defeated ? `공명 격파 -${totalDamage}` : `합동 피해 -${totalDamage}`; } const damage = `-${result.damage}`; 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() { const snapshot = this.sortieOrderHudSnapshot; if (!snapshot) { @@ -22268,6 +22590,7 @@ export class BattleScene extends Phaser.Scene { pointerFeedbackVisible: Boolean(this.pointerFeedbackMarker?.visible), pointerFeedbackTile: this.pointerFeedbackTile ? { ...this.pointerFeedbackTile } : null, bondChainReadyPreview: this.bondChainReadyDebugState(), + bondChainJudgement: this.bondChainJudgementDebugState(), markerCount: this.markers.length, threatMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'threat').length, enemyMoveMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'enemy-move').length,