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(99, 3) === 3, 'Expected overflowing slot to clamp to slot count.');
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(
['elite', 'mobile', 'siege'].every((sortieOrderId) => isValidBattleSaveState({ ...validState, sortieOrderId }, options)),
'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' }] }],
['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' }] }],
['unacted attack intent attacker', { actedUnitIds: [] }],
['enemy attack intent attacker', { attackIntents: [{ attackerId: 'rebel-1', targetId: 'liu-bei' }] }],
['ally attack intent target', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'guan-yu' }] }],
['defeated attack intent attacker', { units: patchUnit(0, { hp: 0 }) }],

View File

@@ -85,6 +85,80 @@ try {
!firstBattleProbe.sideTexts.some((text) => text.includes('...')),
`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, {
battleId: 'first-battle-zhuo-commandery',
orderId: 'elite',