feat: add resonance follow-up attacks

This commit is contained in:
2026-07-11 12:05:02 +09:00
parent 055f7d95c9
commit 9025d74398
4 changed files with 522 additions and 29 deletions

View File

@@ -35,6 +35,23 @@ try {
assert(normalizeBattleSaveSlot(Number.NaN, 3) === 1, 'Expected NaN slot to normalize to slot 1.'); assert(normalizeBattleSaveSlot(Number.NaN, 3) === 1, 'Expected NaN slot to normalize to slot 1.');
assert(normalizeBattleSaveSlot(99, 3) === 3, 'Expected overflowing slot to clamp to slot count.'); assert(normalizeBattleSaveSlot(99, 3) === 3, 'Expected overflowing slot to clamp to slot count.');
assert(isValidBattleSaveState(validState, options), 'Expected valid battle save to pass validation.'); assert(isValidBattleSaveState(validState, options), 'Expected valid battle save to pass validation.');
const validCompletedAttackHistoryState = {
...validState,
actedUnitIds: ['liu-bei', 'guan-yu'],
attackIntents: [
{ attackerId: 'liu-bei', targetId: 'rebel-1' },
{ attackerId: 'guan-yu', targetId: 'rebel-1' }
]
};
assert(
isValidBattleSaveState(validCompletedAttackHistoryState, options),
'Expected unique completed allied attacks against a living enemy to pass validation.'
);
assert(
JSON.stringify(parseBattleSaveState(JSON.stringify(validCompletedAttackHistoryState), options)?.attackIntents) ===
JSON.stringify(validCompletedAttackHistoryState.attackIntents),
'Expected completed allied attack history to round-trip through battle-save parsing.'
);
assert( assert(
['elite', 'mobile', 'siege'].every((sortieOrderId) => isValidBattleSaveState({ ...validState, sortieOrderId }, options)), ['elite', 'mobile', 'siege'].every((sortieOrderId) => isValidBattleSaveState({ ...validState, sortieOrderId }, options)),
'Expected every sortie order id to pass battle-save validation.' 'Expected every sortie order id to pass battle-save validation.'
@@ -218,6 +235,7 @@ try {
['unknown attack intent unit id', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'ghost-unit' }] }], ['unknown attack intent unit id', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'ghost-unit' }] }],
['self-targeting attack intent', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'liu-bei' }] }], ['self-targeting attack intent', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'liu-bei' }] }],
['duplicate attack intent attacker id', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'rebel-1' }, { attackerId: 'liu-bei', targetId: 'rebel-1' }] }], ['duplicate attack intent attacker id', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'rebel-1' }, { attackerId: 'liu-bei', targetId: 'rebel-1' }] }],
['unacted attack intent attacker', { actedUnitIds: [] }],
['enemy attack intent attacker', { attackIntents: [{ attackerId: 'rebel-1', targetId: 'liu-bei' }] }], ['enemy attack intent attacker', { attackIntents: [{ attackerId: 'rebel-1', targetId: 'liu-bei' }] }],
['ally attack intent target', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'guan-yu' }] }], ['ally attack intent target', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'guan-yu' }] }],
['defeated attack intent attacker', { units: patchUnit(0, { hp: 0 }) }], ['defeated attack intent attacker', { units: patchUnit(0, { hp: 0 }) }],

View File

@@ -85,6 +85,80 @@ try {
!firstBattleProbe.sideTexts.some((text) => text.includes('...')), !firstBattleProbe.sideTexts.some((text) => text.includes('...')),
`Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}` `Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}`
); );
await setDebugQueryParam(page, 'debugForceBondChain', '1');
const firstBattleBondChain = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const state = window.__HEROS_DEBUG__?.battle();
const mechanics = state?.combatMechanicsProbe;
const attacker = mechanics?.attackerId ? scene?.debugUnitById(mechanics.attackerId) : undefined;
const partner = mechanics?.partnerId ? scene?.debugUnitById(mechanics.partnerId) : undefined;
const enemy = mechanics?.enemyId ? scene?.debugUnitById(mechanics.enemyId) : undefined;
if (!scene || !attacker || !partner || !enemy) {
return { chain: mechanics?.chain ?? null, forcedRoll: null, preview: null };
}
const originalIntents = scene.attackIntents.map((intent) => ({ ...intent }));
const originalActedUnitIds = new Set(scene.actedUnitIds);
scene.attackIntents = [{ attackerId: partner.id, targetId: enemy.id }];
scene.actedUnitIds = new Set([...originalActedUnitIds, partner.id]);
const preview = scene.combatPreview(attacker, enemy, 'attack');
scene.attackIntents = originalIntents;
scene.actedUnitIds = originalActedUnitIds;
return {
chain: mechanics.chain ?? null,
forcedRoll: scene.debugBondChainRollOverride(),
preview: {
rate: preview.bondChainRate,
damage: preview.bondChainDamage,
bondId: preview.bondChainBondId ?? null,
partnerId: preview.bondChainPartnerId ?? null,
partnerName: preview.bondChainPartnerName ?? null,
label: preview.bondChainLabel ?? null
}
};
});
await setDebugQueryParam(page, 'debugForceBondChain', '0');
const firstBattleBondChainForcedFailure = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
return scene?.debugBondChainRollOverride() ?? null;
});
await setDebugQueryParam(page, 'debugForceBondChain', null);
assert(
firstBattleBondChain.preview?.rate === 18 &&
firstBattleBondChain.preview.damage > 0 &&
firstBattleBondChain.preview.bondId &&
firstBattleBondChain.preview.partnerId === firstBattleBondChain.chain?.expectedSupporterId &&
firstBattleBondChain.preview.partnerName &&
firstBattleBondChain.preview.label,
`Expected a complete level-72 resonance-pursuit preview contract: ${JSON.stringify(firstBattleBondChain)}`
);
assert(
firstBattleBondChain.forcedRoll === true &&
firstBattleBondChainForcedFailure === false &&
firstBattleBondChain.chain?.rate === 18 &&
firstBattleBondChain.chain.supportDamage === firstBattleBondChain.preview.damage &&
firstBattleBondChain.chain.ineligibleWithoutPriorIntent === true &&
firstBattleBondChain.chain.eligibleWithMatchingPriorIntent === true &&
firstBattleBondChain.chain.strategyIgnored === true &&
firstBattleBondChain.chain.counterIgnored === true &&
firstBattleBondChain.chain.missedBaseBlocked === true &&
firstBattleBondChain.chain.lethalBaseBlocked === true &&
firstBattleBondChain.chain.survivingBaseAllowed === true &&
firstBattleBondChain.chain.forcedSuccessTriggers === true &&
firstBattleBondChain.chain.forcedFailureBlocked === true,
`Expected pursuit eligibility, hit/survival gates, and deterministic roll overrides: ${JSON.stringify(firstBattleBondChain)}`
);
assert(
firstBattleBondChain.chain?.statCredit?.partnerDamage === firstBattleBondChain.chain.supportDamage &&
firstBattleBondChain.chain.statCredit.partnerDefeats === 1 &&
firstBattleBondChain.chain.statCredit.partnerActions === 0 &&
firstBattleBondChain.chain.statCredit.defenderDamageTaken === firstBattleBondChain.chain.supportDamage &&
firstBattleBondChain.chain.followUpKillCancelsCounter === true &&
firstBattleBondChain.chain.intentClearedAtTurnReset === true,
`Expected pursuit damage and defeat credit to belong to the supporter, cancel counters on defeat, and reset next turn: ${JSON.stringify(firstBattleBondChain.chain)}`
);
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

@@ -1282,6 +1282,25 @@ type BondCombatBonus = {
extendedRange?: boolean; extendedRange?: boolean;
}; };
type BondChainCandidate = {
bondId: string;
label: string;
partner: UnitData;
rate: number;
damage: number;
};
type BondChainResult = {
bondId: string;
label: string;
partnerId: string;
partnerName: string;
rate: number;
damage: number;
previousDefenderHp: number;
defeated: boolean;
};
type UnitBattleStats = { type UnitBattleStats = {
damageDealt: number; damageDealt: number;
damageTaken: number; damageTaken: number;
@@ -1473,6 +1492,12 @@ type CombatPreview = {
bondLabel?: string; bondLabel?: string;
bondPartnerIds: string[]; bondPartnerIds: string[];
bondExtendedRange: boolean; bondExtendedRange: boolean;
bondChainRate: number;
bondChainDamage: number;
bondChainBondId?: string;
bondChainPartnerId?: string;
bondChainPartnerName?: string;
bondChainLabel?: string;
equipmentDamageBonus: number; equipmentDamageBonus: number;
equipmentDamageReduction: number; equipmentDamageReduction: number;
equipmentHitBonus: number; equipmentHitBonus: number;
@@ -1506,6 +1531,7 @@ type CombatResult = CombatPreview & {
hit: boolean; hit: boolean;
critical: boolean; critical: boolean;
isCounter: boolean; isCounter: boolean;
baseDefeated: boolean;
defeated: boolean; defeated: boolean;
characterGrowth: CharacterGrowthResult; characterGrowth: CharacterGrowthResult;
attackerGrowth: EquipmentGrowthResult; attackerGrowth: EquipmentGrowthResult;
@@ -1518,6 +1544,7 @@ type CombatResult = CombatPreview & {
initiativeBonusDamageAmount: number; initiativeBonusDamageAmount: number;
initiativePreventedDamageAmount: number; initiativePreventedDamageAmount: number;
initiativeReadied: boolean; initiativeReadied: boolean;
bondChain?: BondChainResult;
counter?: CombatResult; counter?: CombatResult;
}; };
@@ -9996,6 +10023,15 @@ export class BattleScene extends Phaser.Scene {
preview.terrainAffinity !== 100 ? `적성${preview.terrainAffinity}%` : '' preview.terrainAffinity !== 100 ? `적성${preview.terrainAffinity}%` : ''
].filter(Boolean); ].filter(Boolean);
if (preview.bondChainPartnerName && preview.bondChainRate > 0 && preview.bondChainDamage > 0) {
summaries.push({
icon: 'leadership',
label: `추격·${preview.bondChainPartnerName}`,
value: `${preview.bondChainRate}% · +${preview.bondChainDamage}`,
tone: palette.gold
});
}
if (preview.action === 'strategy') { if (preview.action === 'strategy') {
summaries.push({ summaries.push({
icon: 'intelligence', icon: 'intelligence',
@@ -10276,6 +10312,15 @@ export class BattleScene extends Phaser.Scene {
if (preview.bondDamageBonus > 0 && preview.bondLabel) { if (preview.bondDamageBonus > 0 && preview.bondLabel) {
detailBadgeX = this.renderPreviewBadge(detailBadgeX, badgeY, 'leadership', `공명 +${preview.bondDamageBonus}%`, maxBadgeRight); detailBadgeX = this.renderPreviewBadge(detailBadgeX, badgeY, 'leadership', `공명 +${preview.bondDamageBonus}%`, maxBadgeRight);
} }
if (preview.bondChainPartnerName && preview.bondChainRate > 0) {
detailBadgeX = this.renderPreviewBadge(
detailBadgeX,
badgeY,
'leadership',
`추격 ${preview.bondChainPartnerName} ${preview.bondChainRate}% +${preview.bondChainDamage}`,
maxBadgeRight
);
}
if (preview.equipmentEffectLabels.length > 0) { if (preview.equipmentEffectLabels.length > 0) {
this.renderPreviewBadge(detailBadgeX, badgeY, 'mastery', '장비 보정', maxBadgeRight); this.renderPreviewBadge(detailBadgeX, badgeY, 'mastery', '장비 보정', maxBadgeRight);
} }
@@ -13114,6 +13159,7 @@ export class BattleScene extends Phaser.Scene {
const attackPower = this.actionPower(attacker, action, usable); const attackPower = this.actionPower(attacker, action, usable);
const defensePower = this.actionDefense(defender, action, terrainRule.defenseBonus); const defensePower = this.actionDefense(defender, action, terrainRule.defenseBonus);
const bondBonus = this.activeBondCombatBonus(attacker, defender, action, isCounter); const bondBonus = this.activeBondCombatBonus(attacker, defender, action, isCounter);
const bondChainCandidate = this.activeBondChainCandidate(attacker, defender, action, isCounter);
const equipmentEffect = this.combatEquipmentEffect(attacker, defender, action, usable, bondBonus); const equipmentEffect = this.combatEquipmentEffect(attacker, defender, action, usable, bondBonus);
const sortieEffect = this.sortieRoleCombatEffect(attacker, defender, action, isCounter, ignoreSortieEffects); const sortieEffect = this.sortieRoleCombatEffect(attacker, defender, action, isCounter, ignoreSortieEffects);
const initiativeEffect = this.tacticalInitiativeCombatEffect( const initiativeEffect = this.tacticalInitiativeCombatEffect(
@@ -13219,6 +13265,12 @@ export class BattleScene extends Phaser.Scene {
bondLabel: bondBonus.label, bondLabel: bondBonus.label,
bondPartnerIds: bondBonus.partnerIds, bondPartnerIds: bondBonus.partnerIds,
bondExtendedRange: Boolean(bondBonus.extendedRange), bondExtendedRange: Boolean(bondBonus.extendedRange),
bondChainRate: bondChainCandidate?.rate ?? 0,
bondChainDamage: bondChainCandidate?.damage ?? 0,
bondChainBondId: bondChainCandidate?.bondId,
bondChainPartnerId: bondChainCandidate?.partner.id,
bondChainPartnerName: bondChainCandidate?.partner.name,
bondChainLabel: bondChainCandidate?.label,
equipmentDamageBonus: equipmentEffect.damageBonus, equipmentDamageBonus: equipmentEffect.damageBonus,
equipmentDamageReduction: equipmentEffect.damageReduction, equipmentDamageReduction: equipmentEffect.damageReduction,
equipmentHitBonus: equipmentEffect.hitBonus, equipmentHitBonus: equipmentEffect.hitBonus,
@@ -13294,6 +13346,79 @@ export class BattleScene extends Phaser.Scene {
stats.sortieBonusHealing += sortieBonusHealing; stats.sortieBonusHealing += sortieBonusHealing;
} }
private recordBondChainStats(partner: UnitData, defender: UnitData, damage: number, defeated: boolean) {
const credit = this.bondChainStatCredit(damage, defeated);
const partnerStats = this.statsFor(partner.id);
partnerStats.damageDealt += credit.partnerDamage;
partnerStats.defeats += credit.partnerDefeats;
this.statsFor(defender.id).damageTaken += credit.defenderDamageTaken;
}
private bondChainStatCredit(damage: number, defeated: boolean) {
return {
partnerDamage: Math.max(0, damage),
partnerDefeats: defeated ? 1 : 0,
partnerActions: 0,
defenderDamageTaken: Math.max(0, damage)
};
}
private canResolveBondChain(preview: CombatPreview, hit: boolean, defenderHp: number) {
return Boolean(
hit &&
defenderHp > 0 &&
preview.action === 'attack' &&
preview.bondChainRate > 0 &&
preview.bondChainDamage > 0 &&
preview.bondChainPartnerId
);
}
private debugBondChainRollOverride() {
if (!this.debugToolsEnabled() || typeof window === 'undefined') {
return undefined;
}
const value = new URLSearchParams(window.location.search).get('debugForceBondChain');
return value === '1' ? true : value === '0' ? false : undefined;
}
private bondChainRollSucceeded(rate: number, forcedRoll = this.debugBondChainRollOverride()) {
return forcedRoll ?? this.rollPercent(rate);
}
private resolveBondChainFollowUp(preview: CombatPreview, hit: boolean): BondChainResult | undefined {
if (!this.canResolveBondChain(preview, hit, preview.defender.hp)) {
return undefined;
}
const partner = battleUnits.find((unit) => unit.id === preview.bondChainPartnerId && unit.hp > 0);
if (!partner) {
return undefined;
}
if (!this.bondChainRollSucceeded(preview.bondChainRate)) {
return undefined;
}
const previousDefenderHp = preview.defender.hp;
const damage = Phaser.Math.Clamp(preview.bondChainDamage, 1, previousDefenderHp);
preview.defender.hp = Math.max(0, preview.defender.hp - damage);
const defeated = preview.defender.hp <= 0;
this.recordBondChainStats(partner, preview.defender, damage, defeated);
this.faceUnitToward(partner, preview.defender);
return {
bondId: preview.bondChainBondId ?? '',
label: preview.bondChainLabel ?? '공명 연계',
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): CombatResult {
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);
@@ -13316,10 +13441,11 @@ export class BattleScene extends Phaser.Scene {
const attackerGrowth = this.awardEquipmentExp(attacker, action === 'attack' ? 'weapon' : 'accessory', this.attackerEquipmentExpGain(attacker, action)); const attackerGrowth = this.awardEquipmentExp(attacker, action === 'attack' ? 'weapon' : 'accessory', this.attackerEquipmentExpGain(attacker, action));
const armorGrowth = this.awardEquipmentExp(defender, 'armor', this.defenderArmorExpGain(defender, hit, preview)); const armorGrowth = this.awardEquipmentExp(defender, 'armor', this.defenderArmorExpGain(defender, hit, preview));
const characterGrowth = this.awardCharacterExp(attacker, this.characterExpGain(preview, damage, hit, critical)); const characterGrowth = this.awardCharacterExp(attacker, this.characterExpGain(preview, damage, hit, critical));
const bondGrowth = attacker.faction === 'ally' && !isCounter ? this.recordBondAttackIntent(attacker, defender) : []; const bondGrowth = attacker.faction === 'ally' && action === 'attack' && !isCounter ? this.recordBondAttackIntent(attacker, defender) : [];
const bondExp = bondGrowth.reduce((total, growth) => total + growth.amount, 0); const bondExp = bondGrowth.reduce((total, growth) => total + growth.amount, 0);
defender.hp = Math.max(0, defender.hp - damage); defender.hp = Math.max(0, defender.hp - damage);
const baseDefeated = defender.hp <= 0;
const statusApplied = const statusApplied =
hit && defender.hp > 0 && usable?.statusEffect hit && defender.hp > 0 && usable?.statusEffect
? this.applyBattleStatus( ? this.applyBattleStatus(
@@ -13345,14 +13471,18 @@ export class BattleScene extends Phaser.Scene {
initiativeBonusDamageAmount, initiativeBonusDamageAmount,
initiativePreventedDamageAmount initiativePreventedDamageAmount
); );
const bondChain = !isCounter ? this.resolveBondChainFollowUp(preview, hit) : undefined;
this.faceUnitToward(attacker, defender); this.faceUnitToward(attacker, defender);
if (hit) { if (hit) {
this.flashDamage(defender, damage, critical, previousDefenderHp); this.flashDamage(defender, damage, critical, previousDefenderHp, bondChain?.previousDefenderHp);
} else { } else {
this.flashMiss(defender, this.missFeedbackLabel(action, usable)); this.flashMiss(defender, this.missFeedbackLabel(action, usable));
} }
if (defender.hp <= 0) { if (defender.hp <= 0) {
this.attackIntents = this.attackIntents.filter(
(intent) => intent.targetId !== defender.id && intent.attackerId !== defender.id
);
this.applyDefeatedStyle(defender); this.applyDefeatedStyle(defender);
} else { } else {
this.syncUnitMotion(defender); this.syncUnitMotion(defender);
@@ -13367,6 +13497,7 @@ export class BattleScene extends Phaser.Scene {
hit, hit,
critical, critical,
isCounter, isCounter,
baseDefeated,
defeated: defender.hp <= 0, defeated: defender.hp <= 0,
characterGrowth, characterGrowth,
attackerGrowth, attackerGrowth,
@@ -13378,7 +13509,8 @@ export class BattleScene extends Phaser.Scene {
sortiePreventedDamageAmount, sortiePreventedDamageAmount,
initiativeBonusDamageAmount, initiativeBonusDamageAmount,
initiativePreventedDamageAmount, initiativePreventedDamageAmount,
initiativeReadied initiativeReadied,
bondChain
}; };
} }
@@ -13660,8 +13792,11 @@ 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
? `\n공명 연계 · ${result.bondChain.partnerName} 추격 피해 +${result.bondChain.damage}${result.bondChain.defeated ? ' · 퇴각' : ''}`
: '';
const summaryCounter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : ''; const summaryCounter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : '';
return `${headline}\n${resolution}${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}`;
} }
@@ -13701,7 +13836,7 @@ export class BattleScene extends Phaser.Scene {
} }
const critical = result.critical ? '치명 ' : ''; const critical = result.critical ? '치명 ' : '';
const defeated = result.defeated ? ' · 퇴각' : ''; const defeated = result.baseDefeated ? ' · 퇴각' : '';
const status = result.statusApplied ? ` · ${this.statusEffectMapText(result.statusApplied)}` : ''; const status = result.statusApplied ? ` · ${this.statusEffectMapText(result.statusApplied)}` : '';
return `${critical}피해 ${result.damage}${defeated}${status}`; return `${critical}피해 ${result.damage}${defeated}${status}`;
} }
@@ -13716,7 +13851,7 @@ export class BattleScene extends Phaser.Scene {
? `치명 ${damage} 피해` ? `치명 ${damage} 피해`
: `${damage} 피해`; : `${damage} 피해`;
const hpLine = result.hit ? ` · HP ${result.previousDefenderHp}${result.defender.hp}` : ''; const hpLine = result.hit ? ` · HP ${result.previousDefenderHp}${result.defender.hp}` : '';
const defeated = result.defeated ? ' · 퇴각' : ''; const defeated = result.baseDefeated ? ' · 퇴각' : '';
const status = result.statusApplied ? ` · ${this.statusEffectMapText(result.statusApplied)}` : ''; const status = result.statusApplied ? ` · ${this.statusEffectMapText(result.statusApplied)}` : '';
return `${result.attacker.name} ${this.combatActionName(result)}${result.defender.name}: ${outcome}${hpLine}${defeated}${status}`; return `${result.attacker.name} ${this.combatActionName(result)}${result.defender.name}: ${outcome}${hpLine}${defeated}${status}`;
} }
@@ -16038,8 +16173,33 @@ export class BattleScene extends Phaser.Scene {
} }
const outcomeCard = this.renderCombatOutcomeCard(result, left + panelWidth / 2 - 226, top + 184, 452, depth + 16); const outcomeCard = this.renderCombatOutcomeCard(result, left + panelWidth / 2 - 226, top + 184, 452, depth + 16);
await this.animateCombatGauge(defenderStatus.hpFill, result.previousDefenderHp / result.defender.maxHp, result.defender.hp / result.defender.maxHp, 420); const hpAfterBaseAttack = result.bondChain?.previousDefenderHp ?? result.defender.hp;
defenderStatus.hpText.setText(`${result.defender.hp} / ${result.defender.maxHp}`); await this.animateCombatGauge(
defenderStatus.hpFill,
result.previousDefenderHp / result.defender.maxHp,
hpAfterBaseAttack / result.defender.maxHp,
420
);
defenderStatus.hpText.setText(`${hpAfterBaseAttack} / ${result.defender.maxHp}`);
if (result.bondChain) {
await this.showBondChainCombatEffect(
result,
defenderSprite,
defenderX,
groundY,
left + panelWidth / 2,
top + 142,
depth + 19
);
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 this.animateGrowthGauge( await this.animateGrowthGauge(
attackerStatus.expFill, attackerStatus.expFill,
@@ -16073,7 +16233,7 @@ export class BattleScene extends Phaser.Scene {
private renderCombatOutcomeSummaryCard(result: CombatResult, x: number, y: number, width: number, depth: number) { private renderCombatOutcomeSummaryCard(result: CombatResult, x: number, y: number, width: number, depth: number) {
const height = 96; const height = 96;
const icon: BattleUiIconKey = result.defeated const icon: BattleUiIconKey = result.baseDefeated
? 'critical' ? 'critical'
: !result.hit : !result.hit
? 'move' ? 'move'
@@ -16105,15 +16265,15 @@ export class BattleScene extends Phaser.Scene {
fixedWidth: 150 fixedWidth: 150
}); });
const detail = result.defeated const detail = result.baseDefeated
? `${result.defender.name} 퇴각` ? `${result.defender.name} 퇴각`
: result.hit : result.hit
? `${result.defender.name} HP ${result.previousDefenderHp}${result.defender.hp}` ? `${result.defender.name} HP ${result.previousDefenderHp}${result.bondChain?.previousDefenderHp ?? result.defender.hp}`
: `${result.defender.name} HP 유지 ${result.defender.hp}/${result.defender.maxHp}`; : `${result.defender.name} HP 유지 ${result.defender.hp}/${result.defender.maxHp}`;
const detailText = this.add.text(86, 39, detail, { const detailText = this.add.text(86, 39, detail, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px', fontSize: '13px',
color: result.defeated ? '#ffb6a6' : '#c8d2dd', color: result.baseDefeated ? '#ffb6a6' : '#c8d2dd',
fontStyle: '700', fontStyle: '700',
fixedWidth: 190 fixedWidth: 190
}); });
@@ -16160,7 +16320,7 @@ export class BattleScene extends Phaser.Scene {
} }
private combatOutcomeTitle(result: CombatResult) { private combatOutcomeTitle(result: CombatResult) {
if (result.defeated) { if (result.baseDefeated) {
return '퇴각'; return '퇴각';
} }
if (!result.hit) { if (!result.hit) {
@@ -16173,7 +16333,7 @@ export class BattleScene extends Phaser.Scene {
} }
private combatOutcomeTone(result: CombatResult) { private combatOutcomeTone(result: CombatResult) {
return result.defeated || result.critical ? 0xd8732c : result.hit ? this.previewAccentColor(result) : 0x83d6ff; return result.baseDefeated || result.critical ? 0xd8732c : result.hit ? this.previewAccentColor(result) : 0x83d6ff;
} }
private combatJudgementChipLabel(result: CombatResult) { private combatJudgementChipLabel(result: CombatResult) {
@@ -16188,9 +16348,12 @@ export class BattleScene extends Phaser.Scene {
} }
private combatOutcomeExtraChip(result: CombatResult): { icon: BattleUiIconKey; label: string; tone: number } | undefined { private combatOutcomeExtraChip(result: CombatResult): { icon: BattleUiIconKey; label: string; tone: number } | undefined {
if (result.defeated) { if (result.baseDefeated) {
return { icon: 'counter', label: '퇴각', tone: 0xd8732c }; return { icon: 'counter', label: '퇴각', tone: 0xd8732c };
} }
if (result.bondChain) {
return { icon: 'leadership', label: `추격 +${result.bondChain.damage}`, tone: palette.gold };
}
if (this.combatCounterWillTrigger(result)) { if (this.combatCounterWillTrigger(result)) {
return { icon: 'counter', label: '반격 예정', tone: 0xb64a45 }; return { icon: 'counter', label: '반격 예정', tone: 0xb64a45 };
} }
@@ -16377,7 +16540,7 @@ export class BattleScene extends Phaser.Scene {
this.renderCombatRibbonBadge(x + 46, y + 38, this.actionIcon(result), this.previewActionTypeLabel(result), depth + 1, 78); this.renderCombatRibbonBadge(x + 46, y + 38, this.actionIcon(result), this.previewActionTypeLabel(result), depth + 1, 78);
this.renderCombatRibbonBadge(x + 130, y + 38, outcomeIcon, outcomeLabel, depth + 1, 62); this.renderCombatRibbonBadge(x + 130, y + 38, outcomeIcon, outcomeLabel, depth + 1, 62);
this.renderCombatRibbonBadge(x + 198, y + 38, 'advantage', result.matchupLabel, depth + 1, 76); this.renderCombatRibbonBadge(x + 198, y + 38, 'advantage', result.matchupLabel, depth + 1, 76);
if (result.defeated) { if (result.baseDefeated) {
this.renderCombatRibbonBadge(x + 280, y + 38, 'counter', '퇴각', depth + 1, 58); this.renderCombatRibbonBadge(x + 280, y + 38, 'counter', '퇴각', depth + 1, 58);
} else if (this.combatCounterWillTrigger(result) || result.counter) { } else if (this.combatCounterWillTrigger(result) || result.counter) {
this.renderCombatRibbonBadge(x + 280, y + 38, 'counter', '반격', depth + 1, 58); this.renderCombatRibbonBadge(x + 280, y + 38, 'counter', '반격', depth + 1, 58);
@@ -17900,7 +18063,8 @@ export class BattleScene extends Phaser.Scene {
return Promise.resolve(); return Promise.resolve();
} }
const partnerUnits = result.bondPartnerIds const effectPartnerIds = result.bondChain ? [result.bondChain.partnerId] : result.bondPartnerIds;
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));
const visiblePartners = partnerUnits.filter((unit) => this.isTileVisible(unit.x, unit.y) && this.unitViews.has(unit.id)); const visiblePartners = partnerUnits.filter((unit) => this.isTileVisible(unit.x, unit.y) && this.unitViews.has(unit.id));
@@ -17941,7 +18105,11 @@ export class BattleScene extends Phaser.Scene {
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.bondExtendedRange ? '삼재진 · 2칸 공명' : '연계 발동', result.bondChain
? `공명 추격 · ${result.bondChain.partnerName}`
: result.bondExtendedRange
? '삼재진 · 2칸 공명'
: '공명 강화',
36 36
)); ));
@@ -18004,12 +18172,90 @@ export class BattleScene extends Phaser.Scene {
this.tweens.add({ targets: aura, scale: 1.7, alpha: 0, duration: 560, ease: 'Sine.easeOut' }); this.tweens.add({ targets: aura, scale: 1.7, alpha: 0, duration: 560, ease: 'Sine.easeOut' });
this.tweens.add({ targets: inner, scale: 1.35, alpha: 0.04, duration: 560, ease: 'Sine.easeOut' }); this.tweens.add({ targets: inner, scale: 1.35, alpha: 0.04, duration: 560, ease: 'Sine.easeOut' });
this.tweens.add({ targets: spark, angle: 180, scale: 1.22, duration: 560, ease: 'Sine.easeInOut' }); this.tweens.add({ targets: spark, angle: 180, scale: 1.22, alpha: 0, duration: 560, ease: 'Sine.easeInOut' });
this.tweens.add({ targets: text, y: textY - 8, scale: 1.05, duration: 180, yoyo: true, ease: 'Sine.easeOut' }); this.tweens.add({ targets: text, y: textY - 8, scale: 1.05, duration: 180, yoyo: true, ease: 'Sine.easeOut' });
this.tweens.add({ targets: text, alpha: 0, duration: 180, delay: 360, ease: 'Sine.easeIn' });
return this.delay(580); return this.delay(580);
} }
private async showBondChainCombatEffect(
result: CombatResult,
defenderSprite: Phaser.GameObjects.Sprite,
defenderX: number,
groundY: number,
textX: number,
textY: number,
depth: number
) {
const chain = result.bondChain;
const partner = chain ? battleUnits.find((unit) => unit.id === chain.partnerId) : undefined;
if (!chain || !partner) {
return;
}
soundDirector.playEffect('bond-resonance', { volume: 0.3, rate: 1.08, stopAfterMs: 780 });
const entryX = defenderX - 230;
const strikeX = defenderX - 82;
const supporterSprite = this.trackCombatObject(
this.add.sprite(
entryX,
groundY,
this.unitActionTexture(partner),
this.unitActionFrameIndex('east', this.actionPoseForCommand('attack'))
)
);
supporterSprite.setDepth(depth);
supporterSprite.setAlpha(0);
this.applyCombatSpriteDisplaySize(supporterSprite, partner);
this.applyActionSpriteBlend(supporterSprite);
const banner = this.trackCombatObject(this.add.text(
textX,
textY,
`공명 추격 ${chain.partnerName}\n추가 피해 +${chain.damage}`,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '20px',
color: '#fff2b8',
fontStyle: '700',
align: 'center',
stroke: '#2b1606',
strokeThickness: 5,
lineSpacing: 2
}
));
banner.setOrigin(0.5);
banner.setDepth(depth + 3);
banner.setScale(0.88);
banner.setAlpha(0);
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);
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);
}
private showSortieAttackCombatEffect(result: CombatResult, x: number, y: number, depth: number) { private showSortieAttackCombatEffect(result: CombatResult, x: number, y: number, depth: number) {
if (result.initiativeBonusDamageAmount > 0) { if (result.initiativeBonusDamageAmount > 0) {
return this.showSortieCombatCue('flank', `맹진 호령 +${result.initiativeBonusDamageAmount}`, x, y, depth); return this.showSortieCombatCue('flank', `맹진 호령 +${result.initiativeBonusDamageAmount}`, x, y, depth);
@@ -18368,7 +18614,7 @@ export class BattleScene extends Phaser.Scene {
this.activeFaction = 'ally'; this.activeFaction = 'ally';
this.fastForwardHeld = false; this.fastForwardHeld = false;
this.actedUnitIds.clear(); this.actedUnitIds.clear();
this.attackIntents = []; this.attackIntents = this.emptyAllyTurnAttackIntents();
const recoveryMessage = this.applyTerrainRecovery('ally'); const recoveryMessage = this.applyTerrainRecovery('ally');
this.resetActedStyles(); this.resetActedStyles();
this.updateTurnText(); this.updateTurnText();
@@ -18398,6 +18644,10 @@ export class BattleScene extends Phaser.Scene {
} }
} }
private emptyAllyTurnAttackIntents(): AttackIntent[] {
return [];
}
private applyTerrainRecovery(faction: ActiveFaction) { private applyTerrainRecovery(faction: ActiveFaction) {
const messages: string[] = []; const messages: string[] = [];
@@ -18818,6 +19068,75 @@ export class BattleScene extends Phaser.Scene {
}); });
} }
private bondChainPartnerEligible(
attacker: UnitData,
partner: UnitData,
defender: UnitData,
bond: BondState,
action: DamageCommand,
isCounter: boolean,
intents: AttackIntent[] = this.attackIntents,
actedUnitIds: ReadonlySet<string> = this.actedUnitIds
) {
if (
isCounter ||
action !== 'attack' ||
attacker.faction !== 'ally' ||
partner.faction !== attacker.faction ||
defender.faction === attacker.faction ||
attacker.hp <= 0 ||
partner.hp <= 0 ||
defender.hp <= 0 ||
sortieBondBonusForLevel(bond.level).chainRate <= 0 ||
!actedUnitIds.has(partner.id) ||
!intents.some((intent) => intent.attackerId === partner.id && intent.targetId === defender.id)
) {
return false;
}
return this.bondPartnerJoinState(attacker, partner, defender).canJoin;
}
private bondChainSupportDamage(partner: UnitData, defender: UnitData) {
const supportPreview = this.combatPreview(partner, defender, 'attack', undefined, true, false, true);
return Phaser.Math.Clamp(supportPreview.damage, 1, defender.maxHp);
}
private activeBondChainCandidate(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false) {
if (isCounter || action !== 'attack' || attacker.faction !== 'ally' || defender.faction === attacker.faction) {
return undefined;
}
const candidates = Array.from(this.bondStates.values())
.map((bond) => {
if (!bond.unitIds.includes(attacker.id)) {
return undefined;
}
const partnerId = bond.unitIds.find((unitId) => unitId !== attacker.id);
const partner = partnerId ? battleUnits.find((unit) => unit.id === partnerId && unit.hp > 0) : undefined;
if (!partner || !this.bondChainPartnerEligible(attacker, partner, defender, bond, action, isCounter)) {
return undefined;
}
return { bond, partner };
})
.filter((candidate): candidate is { bond: BondState; partner: UnitData } => Boolean(candidate))
.sort((left, right) => right.bond.level - left.bond.level || left.bond.id.localeCompare(right.bond.id));
const strongest = candidates[0];
if (!strongest) {
return undefined;
}
const [first, second] = strongest.bond.unitIds.map((id) => this.unitName(id));
return {
bondId: strongest.bond.id,
label: `${strongest.bond.title}: ${first}·${second}`,
partner: strongest.partner,
rate: sortieBondBonusForLevel(strongest.bond.level).chainRate,
damage: this.bondChainSupportDamage(strongest.partner, defender)
} satisfies BondChainCandidate;
}
private activeBondCombatBonus(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false): BondCombatBonus { private activeBondCombatBonus(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false): BondCombatBonus {
if (isCounter || action !== 'attack' || attacker.faction !== 'ally') { if (isCounter || action !== 'attack' || attacker.faction !== 'ally') {
return { damageBonus: 0, chainRate: 0, partnerIds: [] }; return { damageBonus: 0, chainRate: 0, partnerIds: [] };
@@ -19717,14 +20036,14 @@ export class BattleScene extends Phaser.Scene {
this.syncUnitMotion(unit, view, direction); this.syncUnitMotion(unit, view, direction);
} }
private flashDamage(unit: UnitData, damage: number, critical = false, previousHp?: number) { private flashDamage(unit: UnitData, damage: number, critical = false, previousHp?: number, displayedHp = unit.hp) {
const view = this.unitViews.get(unit.id); const view = this.unitViews.get(unit.id);
if (!view) { if (!view) {
return; return;
} }
const title = unit.hp <= 0 ? `${critical ? '치명 ' : ''}퇴각 -${damage}` : `${critical ? '치명 ' : '피해 '}-${damage}`; const title = displayedHp <= 0 ? `${critical ? '치명 ' : ''}퇴각 -${damage}` : `${critical ? '치명 ' : '피해 '}-${damage}`;
const detail = previousHp === undefined ? '' : `HP ${previousHp}${unit.hp}`; const detail = previousHp === undefined ? '' : `HP ${previousHp}${displayedHp}`;
this.showMapResultPopup(unit, [title, detail], critical ? '#ff8f5f' : '#ffdf7b', '#220909', critical ? 20 : 18, critical ? 34 : 30); this.showMapResultPopup(unit, [title, detail], critical ? '#ff8f5f' : '#ffdf7b', '#220909', critical ? 20 : 18, critical ? 34 : 30);
view.sprite.setTintFill(0xffe0a3); view.sprite.setTintFill(0xffe0a3);
@@ -19787,6 +20106,7 @@ export class BattleScene extends Phaser.Scene {
} }
const effectLine = [ const effectLine = [
result.bondChain ? `공명 추격 ${result.bondChain.partnerName} +${result.bondChain.damage}` : '',
this.statusEffectMapText(result.statusApplied), this.statusEffectMapText(result.statusApplied),
this.combatSortieContributionText(result), this.combatSortieContributionText(result),
this.combatInitiativeContributionText(result) this.combatInitiativeContributionText(result)
@@ -19810,8 +20130,12 @@ export class BattleScene extends Phaser.Scene {
} }
private combatMapDamageTitle(result: CombatResult) { private combatMapDamageTitle(result: CombatResult) {
if (result.bondChain) {
const totalDamage = result.damage + result.bondChain.damage;
return result.bondChain.defeated ? `공명 퇴각 -${totalDamage}` : `합동 피해 -${totalDamage}`;
}
const damage = `-${result.damage}`; const damage = `-${result.damage}`;
if (result.defeated) { if (result.baseDefeated) {
return `${result.critical ? '치명 ' : ''}퇴각 ${damage}`; return `${result.critical ? '치명 ' : ''}퇴각 ${damage}`;
} }
return `${result.critical ? '치명 ' : '피해 '}${damage}`; return `${result.critical ? '치명 ' : '피해 '}${damage}`;
@@ -21825,7 +22149,7 @@ export class BattleScene extends Phaser.Scene {
private debugCombatMechanicsProbe() { private debugCombatMechanicsProbe() {
const bond = Array.from(this.bondStates.values()).find((candidate) => { const bond = Array.from(this.bondStates.values()).find((candidate) => {
const units = candidate.unitIds.map((unitId) => battleUnits.find((unit) => unit.id === unitId && unit.hp > 0)); const units = candidate.unitIds.map((unitId) => battleUnits.find((unit) => unit.id === unitId && unit.hp > 0));
return units.length === 2 && units.every((unit) => unit?.faction === 'ally') && sortieBondBonusForLevel(candidate.level).damageBonus > 0; return units.length === 2 && units.every((unit) => unit?.faction === 'ally') && sortieBondBonusForLevel(candidate.level).chainRate > 0;
}); });
const attacker = bond ? battleUnits.find((unit) => unit.id === bond.unitIds[0] && unit.hp > 0) : undefined; const attacker = bond ? battleUnits.find((unit) => unit.id === bond.unitIds[0] && unit.hp > 0) : undefined;
const partner = bond ? battleUnits.find((unit) => unit.id === bond.unitIds[1] && unit.hp > 0) : undefined; const partner = bond ? battleUnits.find((unit) => unit.id === bond.unitIds[1] && unit.hp > 0) : undefined;
@@ -21847,11 +22171,28 @@ export class BattleScene extends Phaser.Scene {
return null; return null;
} }
const adjacentPreview = this.combatPreview({ ...attacker, x: nearX, y: partner.y }, { ...enemy, x: edgeX, y: partner.y }, 'attack'); const syntheticAttacker = { ...attacker, x: nearX, y: partner.y };
const syntheticPartner = { ...partner };
const syntheticEnemy = { ...enemy, x: edgeX, y: partner.y };
const adjacentPreview = this.combatPreview(syntheticAttacker, syntheticEnemy, 'attack');
const sameTargetPreview = this.combatPreview({ ...attacker, x: edgeX, y: partner.y }, { ...enemy, x: nearX, y: partner.y }, 'attack'); const sameTargetPreview = this.combatPreview({ ...attacker, x: edgeX, y: partner.y }, { ...enemy, x: nearX, y: partner.y }, 'attack');
const farPreview = this.combatPreview({ ...attacker, x: farAttackerX, y: partner.y }, { ...enemy, x: farDefenderX, y: partner.y }, 'attack'); const farPreview = this.combatPreview({ ...attacker, x: farAttackerX, y: partner.y }, { ...enemy, x: farDefenderX, y: partner.y }, 'attack');
const normalIncomingPreview = this.combatPreview(enemy, attacker, 'attack'); const normalIncomingPreview = this.combatPreview(enemy, attacker, 'attack');
const counterIncomingPreview = this.combatPreview(enemy, attacker, 'attack', undefined, true); const counterIncomingPreview = this.combatPreview(enemy, attacker, 'attack', undefined, true);
const matchingIntent = [{ attackerId: syntheticPartner.id, targetId: syntheticEnemy.id }];
const syntheticActedUnitIds = new Set([syntheticPartner.id]);
const chainBonus = sortieBondBonusForLevel(bond.level);
const supportDamage = this.bondChainSupportDamage(partner, enemy);
const chainGatePreview: CombatPreview = {
...adjacentPreview,
bondChainRate: chainBonus.chainRate,
bondChainDamage: supportDamage,
bondChainBondId: bond.id,
bondChainPartnerId: syntheticPartner.id,
bondChainPartnerName: syntheticPartner.name,
bondChainLabel: bond.title
};
const statCredit = this.bondChainStatCredit(supportDamage, true);
return { return {
bondId: bond.id, bondId: bond.id,
@@ -21865,7 +22206,64 @@ export class BattleScene extends Phaser.Scene {
farBondDamageBonus: farPreview.bondDamageBonus, farBondDamageBonus: farPreview.bondDamageBonus,
farPartnerIds: farPreview.bondPartnerIds, farPartnerIds: farPreview.bondPartnerIds,
normalDamage: normalIncomingPreview.damage, normalDamage: normalIncomingPreview.damage,
counterDamage: counterIncomingPreview.damage counterDamage: counterIncomingPreview.damage,
chain: {
rate: chainBonus.chainRate,
expectedSupporterId: syntheticPartner.id,
supportDamage,
ineligibleWithoutPriorIntent: !this.bondChainPartnerEligible(
syntheticAttacker,
syntheticPartner,
syntheticEnemy,
bond,
'attack',
false,
[],
syntheticActedUnitIds
),
eligibleWithMatchingPriorIntent: this.bondChainPartnerEligible(
syntheticAttacker,
syntheticPartner,
syntheticEnemy,
bond,
'attack',
false,
matchingIntent,
syntheticActedUnitIds
),
strategyIgnored: !this.bondChainPartnerEligible(
syntheticAttacker,
syntheticPartner,
syntheticEnemy,
bond,
'strategy',
false,
matchingIntent,
syntheticActedUnitIds
),
counterIgnored: !this.bondChainPartnerEligible(
syntheticAttacker,
syntheticPartner,
syntheticEnemy,
bond,
'attack',
true,
matchingIntent,
syntheticActedUnitIds
),
missedBaseBlocked: !this.canResolveBondChain(chainGatePreview, false, syntheticEnemy.hp),
lethalBaseBlocked: !this.canResolveBondChain(chainGatePreview, true, 0),
survivingBaseAllowed: this.canResolveBondChain(chainGatePreview, true, syntheticEnemy.hp),
forcedSuccessTriggers: this.bondChainRollSucceeded(chainBonus.chainRate, true),
forcedFailureBlocked: !this.bondChainRollSucceeded(chainBonus.chainRate, false),
statCredit,
followUpKillCancelsCounter: !this.canCounterAttack(
{ ...syntheticEnemy, hp: 0 },
syntheticAttacker,
'attack'
),
intentClearedAtTurnReset: this.emptyAllyTurnAttackIntents().length === 0
}
}; };
} }

View File

@@ -196,7 +196,7 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida
return false; return false;
} }
if (!areAttackIntentsTargetingLiveUnits(attackIntents, units, options)) { if (!areAttackIntentsTargetingLiveUnits(attackIntents, state.actedUnitIds, units, options)) {
return false; return false;
} }
@@ -282,7 +282,7 @@ function isSortieOrderId(value: unknown): value is SortieOrderId {
return value === 'elite' || value === 'mobile' || value === 'siege'; return value === 'elite' || value === 'mobile' || value === 'siege';
} }
function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) { function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions): value is string[] {
return ( return (
Array.isArray(value) && Array.isArray(value) &&
value.length <= unitReferenceLimit(options) && value.length <= unitReferenceLimit(options) &&
@@ -348,14 +348,17 @@ function areSavedBattleUnitsValid(units: unknown[], options: BattleSaveValidatio
function areAttackIntentsTargetingLiveUnits( function areAttackIntentsTargetingLiveUnits(
attackIntents: BattleSaveAttackIntent[], attackIntents: BattleSaveAttackIntent[],
actedUnitIds: string[],
units: SavedBattleUnitState[], units: SavedBattleUnitState[],
options: BattleSaveValidationOptions options: BattleSaveValidationOptions
) { ) {
const liveUnitIds = new Set(units.filter((unit) => unit.hp > 0).map((unit) => unit.id)); const liveUnitIds = new Set(units.filter((unit) => unit.hp > 0).map((unit) => unit.id));
const actedUnitIdSet = new Set(actedUnitIds);
return attackIntents.every( return attackIntents.every(
(intent) => (intent) =>
liveUnitIds.has(intent.attackerId) && liveUnitIds.has(intent.attackerId) &&
liveUnitIds.has(intent.targetId) && liveUnitIds.has(intent.targetId) &&
actedUnitIdSet.has(intent.attackerId) &&
(!options.validAllyUnitIds || options.validAllyUnitIds.has(intent.attackerId)) && (!options.validAllyUnitIds || options.validAllyUnitIds.has(intent.attackerId)) &&
(!options.validEnemyUnitIds || options.validEnemyUnitIds.has(intent.targetId)) (!options.validEnemyUnitIds || options.validEnemyUnitIds.has(intent.targetId))
); );