feat: preview sortie composition synergy
This commit is contained in:
@@ -868,6 +868,13 @@ try {
|
||||
throw new Error(`Expected sortie prep to default to the core brother roster: ${JSON.stringify(sixthCampSortieState)}`);
|
||||
}
|
||||
assertSortieTacticalRoster(sixthCampSortieState, ['liu-bei', 'guan-yu', 'zhang-fei']);
|
||||
if (
|
||||
sixthCampSortieState.sortieSynergyPreview?.activeBondCount !== 3 ||
|
||||
sixthCampSortieState.sortieSynergyPreview?.coveredRoleCount < 2 ||
|
||||
!sixthCampSortieState.sortieSynergyPreview?.strongestBond?.title
|
||||
) {
|
||||
throw new Error(`Expected the core brother roster to expose three active bonds and a strongest effect: ${JSON.stringify(sixthCampSortieState.sortieSynergyPreview)}`);
|
||||
}
|
||||
|
||||
await clickSortieRosterUnit(page, 'guan-yu');
|
||||
await page.waitForTimeout(220);
|
||||
@@ -880,6 +887,13 @@ try {
|
||||
) {
|
||||
throw new Error(`Expected sortie selection toggle to bench Guan Yu while keeping Liu Bei/Zhang Fei: ${JSON.stringify(sixthCampAfterSortieToggle)}`);
|
||||
}
|
||||
if (
|
||||
sixthCampAfterSortieToggle.sortieSynergyPreview?.activeBondCount !== 1 ||
|
||||
sixthCampAfterSortieToggle.sortieFocusedSynergyPreview?.mode !== 'add' ||
|
||||
sixthCampAfterSortieToggle.sortieFocusedSynergyPreview?.comparison?.activeBondDelta !== 2
|
||||
) {
|
||||
throw new Error(`Expected benching Guan Yu to leave one bond and preview restoring two: ${JSON.stringify(sixthCampAfterSortieToggle.sortieFocusedSynergyPreview)}`);
|
||||
}
|
||||
await page.mouse.click(1068, 646);
|
||||
await waitForStoryReady(page);
|
||||
await page.screenshot({ path: 'dist/verification-seventh-bridge-story.png', fullPage: true });
|
||||
@@ -9610,6 +9624,19 @@ function assertSortieTacticalRoster(state, requiredUnitIds) {
|
||||
if (state.sortieChecklistSummary.complete && state.sortieChecklistSummary.nextIncompleteDetail !== null) {
|
||||
throw new Error(`Expected complete sortie checklist summary to clear next incomplete detail: ${JSON.stringify(state.sortieChecklistSummary)}`);
|
||||
}
|
||||
|
||||
if (
|
||||
!state.sortieSynergyPreview ||
|
||||
!Array.isArray(state.sortieSynergyPreview.selectedUnitIds) ||
|
||||
!Array.isArray(state.sortieSynergyPreview.activeBonds) ||
|
||||
!Array.isArray(state.sortieSynergyPreview.missingRoles) ||
|
||||
typeof state.sortieSynergyPreview.activeBondCount !== 'number' ||
|
||||
typeof state.sortieSynergyPreview.coveredRoleCount !== 'number' ||
|
||||
typeof state.sortieSynergyPreview.terrainScore !== 'number' ||
|
||||
typeof state.sortieSynergyPreview.firstPursuitTrinityConfigured !== 'boolean'
|
||||
) {
|
||||
throw new Error(`Expected sortie composition synergy analysis: ${JSON.stringify(state.sortieSynergyPreview)}`);
|
||||
}
|
||||
if (!state.sortieChecklistSummary.complete && !state.sortieChecklistSummary.nextIncompleteLabel) {
|
||||
throw new Error(`Expected incomplete sortie checklist summary to expose next incomplete item: ${JSON.stringify(state.sortieChecklistSummary)}`);
|
||||
}
|
||||
@@ -9623,6 +9650,8 @@ function assertSortieTacticalRoster(state, requiredUnitIds) {
|
||||
return (
|
||||
!unit.classRole ||
|
||||
!unit.formationRole ||
|
||||
typeof unit.available !== 'boolean' ||
|
||||
typeof unit.availabilityReason !== 'string' ||
|
||||
!unit.statLine?.includes('무 ') ||
|
||||
!unit.statLine?.includes('지 ') ||
|
||||
!unit.equipmentLine?.includes('Lv') ||
|
||||
|
||||
@@ -170,6 +170,19 @@ try {
|
||||
await waitForSortiePrep(page);
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-prep.png`, fullPage: true });
|
||||
await assertCanvasPainted(page, 'first camp sortie prep');
|
||||
await advanceFirstSortiePrepStep(page, 'formation');
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-formation.png`, fullPage: true });
|
||||
await assertCanvasPainted(page, 'first camp sortie formation');
|
||||
const firstSortieSynergy = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieSynergyPreview);
|
||||
assert(
|
||||
firstSortieSynergy?.activeBondCount === 3 &&
|
||||
firstSortieSynergy?.coveredRoleCount === 3 &&
|
||||
firstSortieSynergy?.strongestBond?.damageBonus === 9 &&
|
||||
firstSortieSynergy?.strongestBond?.chainRate === 18 &&
|
||||
firstSortieSynergy?.firstPursuitTrinityConfigured === true,
|
||||
`Expected first sortie to preview three bonds, the strongest effect, and trinity: ${JSON.stringify(firstSortieSynergy)}`
|
||||
);
|
||||
await advanceFirstSortiePrepStep(page, 'loadout');
|
||||
await page.mouse.click(1116, 656);
|
||||
await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit');
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-second-battle-from-first-camp.png`, fullPage: true });
|
||||
@@ -235,13 +248,65 @@ try {
|
||||
`Expected mid-campaign deployment preview to avoid duplicate tiles: ${JSON.stringify(midCampState?.sortieDeploymentPreview)}`
|
||||
);
|
||||
|
||||
const midCampSelectedSortieUnitIds = midCampState.selectedSortieUnitIds ?? [];
|
||||
const midCampDeploymentPreview = midCampState.sortieDeploymentPreview ?? [];
|
||||
const midCampNextBattleId = midCampState.nextSortieBattleId;
|
||||
await page.mouse.click(1160, 38);
|
||||
await waitForSortiePrep(page);
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-prep.png`, fullPage: true });
|
||||
await assertCanvasPainted(page, 'mid camp sortie prep');
|
||||
|
||||
const fullRosterCandidateProbe = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
const state = window.__HEROS_DEBUG__?.camp();
|
||||
const candidate = state?.sortieRoster?.find((unit) => !unit.selected && unit.available);
|
||||
if (!candidate || typeof scene?.toggleSortieUnit !== 'function') {
|
||||
return { candidateId: null, selectedBefore: state?.selectedSortieUnitIds ?? [] };
|
||||
}
|
||||
scene.toggleSortieUnit(candidate.id);
|
||||
return { candidateId: candidate.id, selectedBefore: state.selectedSortieUnitIds ?? [] };
|
||||
});
|
||||
assert(fullRosterCandidateProbe.candidateId, `Expected a full-roster swap candidate: ${JSON.stringify(fullRosterCandidateProbe)}`);
|
||||
await page.waitForFunction((probe) => {
|
||||
const state = window.__HEROS_DEBUG__?.camp();
|
||||
return state?.sortieFocusedUnitId === probe.candidateId &&
|
||||
state?.sortieFocusedSynergyPreview?.mode === 'waiting' &&
|
||||
state?.selectedSortieUnitIds?.length === probe.selectedBefore.length &&
|
||||
!state.selectedSortieUnitIds.includes(probe.candidateId);
|
||||
}, fullRosterCandidateProbe, { timeout: 30000 });
|
||||
|
||||
const underCapacityProbe = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
const state = window.__HEROS_DEBUG__?.camp();
|
||||
const removable = state?.sortieRoster?.find((unit) => unit.selected && !unit.required);
|
||||
if (!removable || typeof scene?.toggleSortieUnit !== 'function') {
|
||||
return { removedUnitId: null, selectedBefore: state?.selectedSortieUnitIds ?? [] };
|
||||
}
|
||||
scene.toggleSortieUnit(removable.id);
|
||||
return { removedUnitId: removable.id, selectedBefore: state.selectedSortieUnitIds ?? [] };
|
||||
});
|
||||
assert(underCapacityProbe.removedUnitId, `Expected a removable mid-campaign officer: ${JSON.stringify(underCapacityProbe)}`);
|
||||
await page.waitForFunction((probe) => {
|
||||
const state = window.__HEROS_DEBUG__?.camp();
|
||||
return state?.sortieVisible === true &&
|
||||
state?.selectedSortieUnitIds?.length === probe.selectedBefore.length - 1 &&
|
||||
!state.selectedSortieUnitIds.includes(probe.removedUnitId) &&
|
||||
state?.sortieFocusedSynergyPreview?.mode === 'add';
|
||||
}, underCapacityProbe, { timeout: 30000 });
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await waitForTitle(page);
|
||||
await page.mouse.click(962, 310);
|
||||
await waitForCamp(page);
|
||||
const restoredUnderCapacityState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
assert(
|
||||
restoredUnderCapacityState?.selectedSortieUnitIds?.length === underCapacityProbe.selectedBefore.length - 1 &&
|
||||
!restoredUnderCapacityState.selectedSortieUnitIds.includes(underCapacityProbe.removedUnitId),
|
||||
`Expected an under-capacity manual formation to survive scene recreation: ${JSON.stringify(restoredUnderCapacityState?.selectedSortieUnitIds)} / ${JSON.stringify(underCapacityProbe)}`
|
||||
);
|
||||
|
||||
const midCampSelectedSortieUnitIds = restoredUnderCapacityState.selectedSortieUnitIds ?? [];
|
||||
const midCampDeploymentPreview = restoredUnderCapacityState.sortieDeploymentPreview ?? [];
|
||||
await page.mouse.click(1160, 38);
|
||||
await waitForSortiePrep(page);
|
||||
await page.mouse.click(1116, 656);
|
||||
await advanceUntilBattle(page, midCampNextBattleId);
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-next-battle.png`, fullPage: true });
|
||||
@@ -467,6 +532,14 @@ async function waitForSortiePrep(page) {
|
||||
}, undefined, { timeout: 30000 });
|
||||
}
|
||||
|
||||
async function advanceFirstSortiePrepStep(page, expectedStep) {
|
||||
await page.mouse.click(1116, 656);
|
||||
await page.waitForFunction((step) => {
|
||||
const camp = window.__HEROS_DEBUG__?.camp?.();
|
||||
return camp?.sortieVisible === true && camp?.sortiePrepStep === step;
|
||||
}, expectedStep, { timeout: 30000 });
|
||||
}
|
||||
|
||||
async function waitForCampAfterBattleResult(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
||||
|
||||
110
scripts/verify-sortie-synergy.mjs
Normal file
110
scripts/verify-sortie-synergy.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const {
|
||||
compareSortieSynergy,
|
||||
evaluateSortieSynergy,
|
||||
firstPursuitSynergyBattleId,
|
||||
sortieBondBonusForLevel
|
||||
} = await server.ssrLoadModule('/src/game/data/sortieSynergy.ts');
|
||||
|
||||
const boundaryCases = [
|
||||
[19, 0, 0],
|
||||
[20, 3, 0],
|
||||
[39, 3, 0],
|
||||
[40, 6, 0],
|
||||
[49, 6, 0],
|
||||
[50, 6, 8],
|
||||
[69, 9, 8],
|
||||
[70, 9, 18],
|
||||
[100, 15, 18]
|
||||
];
|
||||
boundaryCases.forEach(([level, damageBonus, chainRate]) => {
|
||||
const actual = sortieBondBonusForLevel(level);
|
||||
assert(
|
||||
actual.damageBonus === damageBonus && actual.chainRate === chainRate,
|
||||
`Unexpected Lv${level} bond bonus: ${JSON.stringify(actual)}`
|
||||
);
|
||||
});
|
||||
|
||||
const members = [
|
||||
{ id: 'liu-bei', name: '유비', role: 'support', terrainScore: 101 },
|
||||
{ id: 'guan-yu', name: '관우', role: 'front', terrainScore: 105 },
|
||||
{ id: 'zhang-fei', name: '장비', role: 'flank', terrainScore: 98 },
|
||||
{ id: 'jian-yong', name: '간옹', role: 'support', terrainScore: 104 }
|
||||
];
|
||||
const bonds = [
|
||||
{ id: 'oath', title: '도원결의', unitIds: ['liu-bei', 'guan-yu'], level: 72, exp: 4 },
|
||||
{ id: 'brothers', title: '의형제', unitIds: ['guan-yu', 'zhang-fei'], level: 68, exp: 3 },
|
||||
{ id: 'vow', title: '맹세', unitIds: ['liu-bei', 'zhang-fei'], level: 64, exp: 2 },
|
||||
{ id: 'records', title: '군영 기록', unitIds: ['liu-bei', 'jian-yong'], level: 51, exp: 1 },
|
||||
{ id: 'self', title: '잘못된 관계', unitIds: ['liu-bei', 'liu-bei'], level: 99, exp: 0 },
|
||||
{ id: 'unknown', title: '알 수 없음', unitIds: ['liu-bei', 'ghost'], level: 99, exp: 0 }
|
||||
];
|
||||
const frozenInputs = JSON.stringify({ members, bonds });
|
||||
|
||||
const trio = evaluateSortieSynergy({
|
||||
members,
|
||||
bonds,
|
||||
selectedUnitIds: ['liu-bei', 'guan-yu', 'zhang-fei', 'guan-yu'],
|
||||
battleId: firstPursuitSynergyBattleId
|
||||
});
|
||||
assert(trio.selectedUnitIds.length === 3, `Expected duplicate selections to collapse: ${JSON.stringify(trio.selectedUnitIds)}`);
|
||||
assert(trio.activeBondCount === 3, `Expected three active brother bonds: ${JSON.stringify(trio.activeBonds)}`);
|
||||
assert(trio.strongestBond?.id === 'oath', `Expected Lv72 oath to be strongest: ${JSON.stringify(trio.strongestBond)}`);
|
||||
assert(trio.strongestBond?.damageBonus === 9 && trio.strongestBond?.chainRate === 18, 'Expected strongest combat preview to match BattleScene rules.');
|
||||
assert(trio.coveredRoleCount === 3 && trio.firstPursuitTrinityConfigured, `Expected full role coverage and trinity: ${JSON.stringify(trio)}`);
|
||||
|
||||
const withoutGuan = evaluateSortieSynergy({
|
||||
members,
|
||||
bonds,
|
||||
selectedUnitIds: ['liu-bei', 'zhang-fei'],
|
||||
battleId: firstPursuitSynergyBattleId
|
||||
});
|
||||
assert(withoutGuan.activeBondCount === 1 && withoutGuan.activeBonds[0]?.id === 'vow', `Expected only the Liu-Zhang bond after Guan Yu leaves: ${JSON.stringify(withoutGuan.activeBonds)}`);
|
||||
assert(withoutGuan.coveredRoleCount === 2 && !withoutGuan.firstPursuitTrinityConfigured, 'Expected removing Guan Yu to break role coverage and trinity.');
|
||||
|
||||
const removal = compareSortieSynergy(trio, withoutGuan);
|
||||
assert(removal.activeBondDelta === -2 && removal.lostBonds.length === 2, `Expected two lost bonds: ${JSON.stringify(removal)}`);
|
||||
assert(removal.roleCoverageDelta === -1 && removal.lostRoles[0] === 'front', `Expected the front role to be lost: ${JSON.stringify(removal.lostRoles)}`);
|
||||
const addition = compareSortieSynergy(withoutGuan, trio);
|
||||
assert(addition.activeBondDelta === 2 && addition.gainedBonds.length === 2, `Expected adding Guan Yu to restore two bonds: ${JSON.stringify(addition)}`);
|
||||
assert(addition.roleCoverageDelta === 1 && addition.gainedRoles[0] === 'front', `Expected adding Guan Yu to restore the front role: ${JSON.stringify(addition.gainedRoles)}`);
|
||||
|
||||
const restoredRoles = evaluateSortieSynergy({
|
||||
members: members.map((member) => member.id === 'guan-yu' ? { ...member, role: 'support' } : member),
|
||||
bonds,
|
||||
selectedUnitIds: ['liu-bei', 'guan-yu', 'zhang-fei'],
|
||||
battleId: firstPursuitSynergyBattleId
|
||||
});
|
||||
assert(restoredRoles.activeBondCount === 3, 'Expected role changes not to alter active bonds.');
|
||||
assert(restoredRoles.coveredRoleCount === 2 && !restoredRoles.firstPursuitTrinityConfigured, 'Expected duplicate support roles to disable trinity.');
|
||||
|
||||
const reordered = evaluateSortieSynergy({
|
||||
members: [...members].reverse(),
|
||||
bonds: [...bonds].reverse(),
|
||||
selectedUnitIds: ['zhang-fei', 'guan-yu', 'liu-bei'],
|
||||
battleId: firstPursuitSynergyBattleId
|
||||
});
|
||||
assert(
|
||||
reordered.activeBonds.map((bond) => bond.id).join(',') === trio.activeBonds.map((bond) => bond.id).join(','),
|
||||
`Expected deterministic bond ordering: ${JSON.stringify(reordered.activeBonds)}`
|
||||
);
|
||||
assert(JSON.stringify({ members, bonds }) === frozenInputs, 'Expected synergy evaluation not to mutate its inputs.');
|
||||
|
||||
console.log('Verified sortie synergy previews, bond thresholds, role coverage, and first-pursuit trinity.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const checks = [
|
||||
'scripts/verify-campaign-flow-data.mjs',
|
||||
'scripts/verify-campaign-save-normalization.mjs',
|
||||
'scripts/verify-battle-save-normalization.mjs',
|
||||
'scripts/verify-sortie-synergy.mjs',
|
||||
'scripts/verify-battle-scenario-data.mjs',
|
||||
'scripts/verify-camp-reward-data.mjs',
|
||||
'scripts/verify-campaign-recruit-data.mjs',
|
||||
|
||||
Reference in New Issue
Block a user