feat: preview resonance pursuit pairs in formation

This commit is contained in:
2026-07-11 12:50:34 +09:00
parent 9025d74398
commit 307040ef00
4 changed files with 714 additions and 67 deletions

View File

@@ -993,6 +993,273 @@ try {
firstSortieSynergy?.firstPursuitTrinityConfigured === true,
`Expected first sortie to preview three bonds, the strongest effect, and trinity: ${JSON.stringify(firstSortieSynergy)}`
);
const expectedFirstSortiePursuitPairs = [
{
id: 'liu-bei__guan-yu',
unitIds: ['liu-bei', 'guan-yu'],
unitNames: ['유비', '관우'],
title: '도원결의',
level: 72,
chainRate: 18,
damageBonus: 9
},
{
id: 'liu-bei__zhang-fei',
unitIds: ['liu-bei', 'zhang-fei'],
unitNames: ['유비', '장비'],
title: '의형제',
level: 68,
chainRate: 8,
damageBonus: 9
},
{
id: 'guan-yu__zhang-fei',
unitIds: ['guan-yu', 'zhang-fei'],
unitNames: ['관우', '장비'],
title: '맹장 공명',
level: 64,
chainRate: 8,
damageBonus: 9
}
];
const firstSortiePursuit = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview);
const logicalViewportBounds = { x: 0, y: 0, width: 1280, height: 720 };
assert(
sameJsonValue(firstSortiePursuit?.activePairs, expectedFirstSortiePursuitPairs) &&
firstSortiePursuit?.selectableOpportunities?.length === 0,
`Expected the selected founding trio to expose ordered 18/8/8 resonance-pursuit pairs without reserve opportunities: ${JSON.stringify(firstSortiePursuit)}`
);
assert(
firstSortiePursuit?.summaryText === '조합 시너지 · 추격 후보 3조 · 역할 3/3' &&
firstSortiePursuit?.ruleText === '같은 적을 먼저 친 공명 장수가 지원 거리 안에서 표시 확률로 추격합니다.' &&
isFiniteBounds(firstSortiePursuit.panelBounds) &&
isFiniteBounds(firstSortiePursuit.ruleBounds) &&
boundsInside(firstSortiePursuit.panelBounds, logicalViewportBounds) &&
boundsInside(firstSortiePursuit.ruleBounds, firstSortiePursuit.panelBounds) &&
sameJsonValue(firstSortiePursuit.rows?.map((row) => ({ state: row.state, text: row.text })), [
{ state: 'candidate', text: '18% 유비↔관우' },
{ state: 'candidate', text: '8% 유비↔장비' },
{ state: 'candidate', text: '8% 관우↔장비' }
]) &&
firstSortiePursuit.rows.every(
(row) =>
isFiniteBounds(row.bounds) &&
isFiniteBounds(row.textBounds) &&
boundsInside(row.bounds, firstSortiePursuit.panelBounds) &&
boundsInside(row.textBounds, row.bounds, 2)
),
`Expected all three pursuit chips and their labels to remain visible inside the first-sortie panel: ${JSON.stringify(firstSortiePursuit)}`
);
const firstSortiePursuitSaveBefore = await readCampaignSave(page);
const firstSortiePursuitMutation = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (
!scene ||
typeof scene.persistSortieSelection !== 'function' ||
typeof scene.showSortiePrep !== 'function'
) {
return { ready: false };
}
const before = {
selectedUnitIds: [...scene.selectedSortieUnitIds],
formationAssignments: { ...scene.sortieFormationAssignments },
itemAssignments: structuredClone(scene.sortieItemAssignments ?? {}),
focusedUnitId: scene.sortieFocusedUnitId ?? null,
planFeedback: scene.sortiePlanFeedback,
rosterScroll: scene.sortieRosterScroll
};
scene.selectedSortieUnitIds = scene.selectedSortieUnitIds.filter((unitId) => unitId !== 'guan-yu');
const formationAssignments = { ...scene.sortieFormationAssignments };
delete formationAssignments['guan-yu'];
scene.sortieFormationAssignments = formationAssignments;
const itemAssignments = structuredClone(scene.sortieItemAssignments ?? {});
delete itemAssignments['guan-yu'];
scene.sortieItemAssignments = itemAssignments;
scene.sortieFocusedUnitId = 'guan-yu';
const state = window.__HEROS_DEBUG__?.camp();
return {
ready: true,
before,
after: {
selectedUnitIds: state?.selectedSortieUnitIds,
formationAssignments: state?.sortieFormationAssignments,
itemAssignments: state?.sortieItemAssignments,
focusedUnitId: state?.sortieFocusedUnitId,
pursuit: state?.sortiePursuitPreview,
focusedSynergy: state?.sortieFocusedSynergyPreview
}
};
});
const expectedGuanYuPursuitOpportunities = [
{
...expectedFirstSortiePursuitPairs[0],
selectedPartnerId: 'liu-bei',
selectedPartnerName: '유비',
candidateUnitId: 'guan-yu',
candidateUnitName: '관우'
},
{
...expectedFirstSortiePursuitPairs[2],
selectedPartnerId: 'zhang-fei',
selectedPartnerName: '장비',
candidateUnitId: 'guan-yu',
candidateUnitName: '관우'
}
];
assert(
firstSortiePursuitMutation.ready === true &&
!firstSortiePursuitMutation.after?.selectedUnitIds?.includes('guan-yu') &&
firstSortiePursuitMutation.after?.focusedUnitId === 'guan-yu' &&
!Object.prototype.hasOwnProperty.call(firstSortiePursuitMutation.after?.formationAssignments ?? {}, 'guan-yu') &&
!Object.prototype.hasOwnProperty.call(firstSortiePursuitMutation.after?.itemAssignments ?? {}, 'guan-yu'),
`Expected the RC fixture to bench Guan Yu and clear only his live role and supply assignments: ${JSON.stringify(firstSortiePursuitMutation)}`
);
assert(
sameJsonValue(firstSortiePursuitMutation.after?.pursuit?.activePairs, [expectedFirstSortiePursuitPairs[1]]) &&
sameJsonValue(
firstSortiePursuitMutation.after?.pursuit?.selectableOpportunities,
expectedGuanYuPursuitOpportunities
),
`Expected benching Guan Yu to leave one active 8% pursuit and expose his 18%/8% partner opportunities: ${JSON.stringify(firstSortiePursuitMutation.after?.pursuit)}`
);
assert(
firstSortiePursuitMutation.after?.focusedSynergy?.mode === 'add' &&
firstSortiePursuitMutation.after.focusedSynergy.comparison?.activeBondDelta === 2 &&
sameJsonValue(
firstSortiePursuitMutation.after.focusedSynergy.comparison.gainedBonds?.map((bond) => bond.id),
['liu-bei__guan-yu', 'guan-yu__zhang-fei']
) &&
firstSortiePursuitMutation.after.focusedSynergy.comparison.lostBonds?.length === 0 &&
sameJsonValue(
firstSortiePursuitMutation.after.focusedSynergy.projected?.activeBonds?.map((bond) => bond.id),
expectedFirstSortiePursuitPairs.map((pair) => pair.id)
),
`Expected Guan Yu's focused add projection to restore both pursuit bonds and the full trio: ${JSON.stringify(firstSortiePursuitMutation.after?.focusedSynergy)}`
);
const firstSortiePursuitRemoveProjection = await page.evaluate((before) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (!scene || typeof scene.persistSortieSelection !== 'function' || typeof scene.showSortiePrep !== 'function') {
return { ready: false };
}
const scenario = scene.nextSortieScenario();
const current = scene.sortieSynergySnapshot(before.selectedUnitIds, scenario, before.formationAssignments);
const projectedUnitIds = before.selectedUnitIds.filter((unitId) => unitId !== 'guan-yu');
const projectedFormationAssignments = { ...before.formationAssignments };
delete projectedFormationAssignments['guan-yu'];
const projected = scene.sortieSynergySnapshot(projectedUnitIds, scenario, projectedFormationAssignments);
const pursuitChanges = scene.changedSortiePursuitPairs(current, projected);
scene.selectedSortieUnitIds = [...before.selectedUnitIds];
scene.sortieFormationAssignments = { ...before.formationAssignments };
scene.sortieItemAssignments = structuredClone(before.itemAssignments);
scene.sortieFocusedUnitId = before.focusedUnitId ?? undefined;
scene.sortiePlanFeedback = before.planFeedback;
scene.sortieRosterScroll = before.rosterScroll;
scene.persistSortieSelection();
scene.showSortiePrep();
const state = window.__HEROS_DEBUG__?.camp();
return {
ready: true,
selectedUnitIds: state?.selectedSortieUnitIds,
pursuit: state?.sortiePursuitPreview,
removeProjection: {
currentPairIds: pursuitChanges.currentPairs.map((pair) => pair.id),
projectedPairIds: pursuitChanges.projectedPairs.map((pair) => pair.id),
gainedPairIds: pursuitChanges.gainedPairs.map((pair) => pair.id),
lostPairIds: pursuitChanges.lostPairs.map((pair) => pair.id),
activePairDelta: pursuitChanges.projectedPairs.length - pursuitChanges.currentPairs.length
}
};
}, firstSortiePursuitMutation.before);
assert(
firstSortiePursuitRemoveProjection.ready === true &&
sameJsonValue(firstSortiePursuitRemoveProjection.selectedUnitIds, firstSortiePursuitMutation.before?.selectedUnitIds) &&
sameJsonValue(firstSortiePursuitRemoveProjection.pursuit?.activePairs, expectedFirstSortiePursuitPairs) &&
firstSortiePursuitRemoveProjection.pursuit?.selectableOpportunities?.length === 0 &&
sameJsonValue(
firstSortiePursuitRemoveProjection.removeProjection?.currentPairIds,
expectedFirstSortiePursuitPairs.map((pair) => pair.id)
) &&
sameJsonValue(firstSortiePursuitRemoveProjection.removeProjection?.projectedPairIds, ['liu-bei__zhang-fei']) &&
firstSortiePursuitRemoveProjection.removeProjection?.activePairDelta === -2 &&
sameJsonValue(
firstSortiePursuitRemoveProjection.removeProjection?.lostPairIds,
['liu-bei__guan-yu', 'guan-yu__zhang-fei']
) &&
firstSortiePursuitRemoveProjection.removeProjection?.gainedPairIds?.length === 0,
`Expected the restored trio to preview exactly the two pursuit pairs lost by removing Guan Yu: ${JSON.stringify(firstSortiePursuitRemoveProjection)}`
);
const firstSortiePursuitRestored = await page.evaluate((before) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (!scene || typeof scene.persistSortieSelection !== 'function' || typeof scene.showSortiePrep !== 'function') {
return { ready: false };
}
scene.selectedSortieUnitIds = [...before.selectedUnitIds];
scene.sortieFormationAssignments = { ...before.formationAssignments };
scene.sortieItemAssignments = structuredClone(before.itemAssignments);
scene.sortieFocusedUnitId = before.focusedUnitId ?? undefined;
scene.sortiePlanFeedback = before.planFeedback;
scene.sortieRosterScroll = before.rosterScroll;
scene.persistSortieSelection();
scene.showSortiePrep();
const state = window.__HEROS_DEBUG__?.camp();
return {
ready: true,
selectedUnitIds: state?.selectedSortieUnitIds,
formationAssignments: state?.sortieFormationAssignments,
itemAssignments: state?.sortieItemAssignments,
focusedUnitId: state?.sortieFocusedUnitId ?? null,
planFeedback: state?.sortiePlanFeedback,
rosterScroll: scene.sortieRosterScroll,
deploymentPreview: state?.sortieDeploymentPreview,
pursuit: state?.sortiePursuitPreview
};
}, firstSortiePursuitMutation.before);
const firstSortiePursuitSaveRestored = await readCampaignSave(page);
assert(
firstSortiePursuitRestored.ready === true &&
sameJsonValue(firstSortiePursuitRestored.selectedUnitIds, firstSortiePursuitMutation.before?.selectedUnitIds) &&
sameJsonValue(firstSortiePursuitRestored.formationAssignments, firstSortiePursuitMutation.before?.formationAssignments) &&
sameJsonValue(firstSortiePursuitRestored.itemAssignments, firstSortiePursuitMutation.before?.itemAssignments) &&
firstSortiePursuitRestored.focusedUnitId === firstSortiePursuitMutation.before?.focusedUnitId &&
firstSortiePursuitRestored.planFeedback === firstSortiePursuitMutation.before?.planFeedback &&
firstSortiePursuitRestored.rosterScroll === firstSortiePursuitMutation.before?.rosterScroll &&
sameJsonValue(firstSortiePursuitRestored.deploymentPreview, firstCampDeploymentPreview) &&
sameJsonValue(firstSortiePursuitRestored.pursuit?.activePairs, expectedFirstSortiePursuitPairs),
`Expected the pursuit RC fixture to restore the exact live formation, roles, supplies, focus, feedback, scroll, and deployment: ${JSON.stringify(firstSortiePursuitRestored)}`
);
assert(
sameJsonValue(
firstSortiePursuitSaveRestored.current?.selectedSortieUnitIds,
firstSortiePursuitSaveBefore.current?.selectedSortieUnitIds
) &&
sameJsonValue(
firstSortiePursuitSaveRestored.current?.sortieFormationAssignments,
firstSortiePursuitSaveBefore.current?.sortieFormationAssignments
) &&
sameJsonValue(
firstSortiePursuitSaveRestored.current?.sortieItemAssignments,
firstSortiePursuitSaveBefore.current?.sortieItemAssignments
) &&
sameJsonValue(
firstSortiePursuitSaveRestored.slot1?.selectedSortieUnitIds,
firstSortiePursuitSaveBefore.slot1?.selectedSortieUnitIds
) &&
sameJsonValue(
firstSortiePursuitSaveRestored.slot1?.sortieFormationAssignments,
firstSortiePursuitSaveBefore.slot1?.sortieFormationAssignments
) &&
sameJsonValue(
firstSortiePursuitSaveRestored.slot1?.sortieItemAssignments,
firstSortiePursuitSaveBefore.slot1?.sortieItemAssignments
),
`Expected the pursuit RC fixture to restore current and slot-1 sortie saves before launch: ${JSON.stringify(firstSortiePursuitSaveRestored)}`
);
await advanceSortiePrepStep(page, 'loadout');
await page.mouse.click(1116, 656);
await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit');
@@ -1321,9 +1588,10 @@ try {
midFormationSwapPreviewProbe.panel.title === '가상 교체 · 마대 → 관우' &&
midFormationSwapPreviewProbe.panel.headline?.includes('OUT 마대') &&
midFormationSwapPreviewProbe.panel.headline?.includes('IN 관우') &&
midFormationSwapPreviewProbe.panel.impact?.includes('추격') &&
midFormationSwapPreviewProbe.panel.impact?.includes('공백 돌파') &&
midFormationSwapPreviewProbe.panel.detail?.includes('+맥성 북문') &&
midFormationSwapPreviewProbe.panel.detail?.includes('-산길과 말발굽') &&
midFormationSwapPreviewProbe.panel.detail?.includes('추격 해제') &&
midFormationSwapPreviewProbe.panel.detail?.includes('8%') &&
midFormationSwapPreviewProbe.panel.detail?.includes('실제 편성 미변경'),
`Expected the visible comparison panel to render the swap preview: ${JSON.stringify(midFormationSwapPreviewProbe.panel)}`
);

View File

@@ -9,6 +9,7 @@ const server = await createServer({
try {
const {
compareSortieSynergy,
evaluateSortiePursuitPairs,
evaluateSortieSynergy,
firstPursuitRolePreviewSummaries,
firstPursuitSynergyBattleId,
@@ -121,6 +122,70 @@ try {
);
assert(JSON.stringify({ members, bonds }) === frozenInputs, 'Expected synergy evaluation not to mutate its inputs.');
const pursuitMembers = [
{ id: 'selected-a', name: 'Selected A', role: 'front', terrainScore: 100 },
{ id: 'selected-b', name: 'Selected B', role: 'flank', terrainScore: 100 },
{ id: 'candidate-70', name: 'Candidate 70', role: 'support', terrainScore: 100 },
{ id: 'candidate-50', name: 'Candidate 50', role: 'support', terrainScore: 100 },
{ id: 'candidate-49', name: 'Candidate 49', role: 'support', terrainScore: 100 }
];
const pursuitBonds = [
{ id: 'active-70-zeta', title: 'Active Tie', unitIds: ['selected-a', 'selected-b'], level: 70 },
{ id: 'active-70-alpha', title: 'Active Tie', unitIds: ['selected-b', 'selected-a'], level: 70 },
{ id: 'active-50', title: 'Active 50', unitIds: ['selected-a', 'selected-b'], level: 50 },
{ id: 'opportunity-70-zeta', title: 'Zeta Opportunity', unitIds: ['selected-a', 'candidate-70'], level: 70 },
{ id: 'opportunity-70-alpha', title: 'Alpha Opportunity', unitIds: ['candidate-70', 'selected-a'], level: 70 },
{ id: 'opportunity-50-duplicate', title: 'Opportunity 50 Duplicate', unitIds: ['selected-a', 'candidate-70'], level: 50 },
{ id: 'opportunity-50', title: 'Opportunity 50', unitIds: ['selected-b', 'candidate-50'], level: 50 },
{ id: 'opportunity-49', title: 'Opportunity 49', unitIds: ['selected-a', 'candidate-49'], level: 49 }
];
const frozenPursuitInputs = JSON.stringify({ pursuitMembers, pursuitBonds });
const pursuitPairs = evaluateSortiePursuitPairs({
members: pursuitMembers,
bonds: pursuitBonds,
selectedUnitIds: ['selected-a', 'selected-b', 'selected-a']
});
assert(
pursuitPairs.activePairs.map((bond) => `${bond.id}:${bond.chainRate}`).join(',') === 'active-70-alpha:18',
`Expected a selected pair to keep its highest-level bond and resolve level ties by title/id while normalizing duplicate selections: ${JSON.stringify(pursuitPairs.activePairs)}`
);
assert(
pursuitPairs.selectableOpportunities.map((bond) => `${bond.id}:${bond.chainRate}`).join(',') === 'opportunity-70-alpha:18,opportunity-50:8',
`Expected opportunities to keep one strongest unordered pair, resolve ties deterministically, expose Lv70/Lv50 rates, and hide Lv49: ${JSON.stringify(pursuitPairs.selectableOpportunities)}`
);
assert(
pursuitPairs.selectableOpportunities[0]?.selectedPartnerId === 'selected-a' &&
pursuitPairs.selectableOpportunities[0]?.selectedPartnerName === 'Selected A' &&
pursuitPairs.selectableOpportunities[0]?.candidateUnitId === 'candidate-70' &&
pursuitPairs.selectableOpportunities[0]?.candidateUnitName === 'Candidate 70',
`Expected pursuit opportunities to identify the selected partner and selectable candidate: ${JSON.stringify(pursuitPairs.selectableOpportunities[0])}`
);
const unavailablePursuitPairs = evaluateSortiePursuitPairs({
members: pursuitMembers,
bonds: pursuitBonds,
selectedUnitIds: ['selected-a', 'selected-b'],
selectableUnitIds: ['selected-a', 'selected-b', 'candidate-50', 'candidate-49']
});
assert(
unavailablePursuitPairs.selectableOpportunities.length === 1 &&
unavailablePursuitPairs.selectableOpportunities[0]?.candidateUnitId === 'candidate-50',
`Expected unavailable candidates and already-selected ids to stay out of pursuit opportunities: ${JSON.stringify(unavailablePursuitPairs.selectableOpportunities)}`
);
const promotedPursuitPairs = evaluateSortiePursuitPairs({
members: pursuitMembers,
bonds: pursuitBonds,
selectedUnitIds: ['selected-a', 'selected-b', 'candidate-70']
});
assert(
promotedPursuitPairs.activePairs.some((bond) => bond.id === 'opportunity-70-alpha' && bond.chainRate === 18) &&
promotedPursuitPairs.selectableOpportunities.every((bond) => bond.candidateUnitId !== 'candidate-70'),
`Expected a selected candidate to move from opportunity to active pair: ${JSON.stringify(promotedPursuitPairs)}`
);
assert(
JSON.stringify({ pursuitMembers, pursuitBonds }) === frozenPursuitInputs,
'Expected pursuit-pair evaluation not to mutate its inputs.'
);
const orderPerformance = {
selectedUnitIds: ['front-unit', 'flank-unit', 'support-unit'],
formationAssignments: {