fix: block sorties with incapacitated required officers
This commit is contained in:
@@ -3352,6 +3352,381 @@ try {
|
||||
`Expected the victory report to surface persisted core-resonance attempts, successes, and damage: ${JSON.stringify(secondBattleCoreResult)}`
|
||||
);
|
||||
|
||||
const requiredCasualtySaveBefore = await readCampaignSave(page);
|
||||
try {
|
||||
await seedCampaignSave(page, {
|
||||
battleId: 'seventeenth-battle-wolong-visit-road',
|
||||
battleTitle: '융중 방문로',
|
||||
step: 'seventeenth-camp',
|
||||
gold: 2400,
|
||||
turnNumber: 12,
|
||||
defeatedEnemies: 10,
|
||||
selectedSortieUnitIds: ['liu-bei', 'zhuge-liang', 'zhao-yun', 'guan-yu', 'zhang-fei', 'sun-qian']
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await waitForTitle(page);
|
||||
await page.mouse.click(962, 310);
|
||||
await waitForCamp(page);
|
||||
|
||||
const requiredCasualtyRecruitment = await page.evaluate(() => {
|
||||
const state = window.__HEROS_DEBUG__?.camp();
|
||||
const unit = state?.campaign?.roster?.find((candidate) => candidate.id === 'zhuge-liang');
|
||||
return {
|
||||
nextBattleId: state?.nextSortieBattleId ?? null,
|
||||
unit: unit ? { id: unit.id, hp: unit.hp, maxHp: unit.maxHp } : null
|
||||
};
|
||||
});
|
||||
assert(
|
||||
requiredCasualtyRecruitment.nextBattleId === 'eighteenth-battle-bowang-ambush' &&
|
||||
requiredCasualtyRecruitment.unit?.hp > 0,
|
||||
'Expected the seventeenth camp to recruit Zhuge Liang before the casualty fixture: ' +
|
||||
JSON.stringify(requiredCasualtyRecruitment)
|
||||
);
|
||||
|
||||
const requiredCasualtySeed = await page.evaluate(() => {
|
||||
const keys = ['heros-web:campaign-state', 'heros-web:campaign-state:slot-1'];
|
||||
const seeded = [];
|
||||
for (const key of keys) {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
const campaign = raw ? JSON.parse(raw) : null;
|
||||
const target = campaign?.roster?.find((unit) => unit.id === 'zhuge-liang');
|
||||
if (!campaign || !target || !campaign.firstBattleReport) {
|
||||
seeded.push({ key, ready: false, rosterCount: campaign?.roster?.length ?? 0 });
|
||||
continue;
|
||||
}
|
||||
target.hp = 0;
|
||||
const reportUnit = campaign.firstBattleReport.units?.find((unit) => unit.id === target.id);
|
||||
if (reportUnit) {
|
||||
reportUnit.hp = 0;
|
||||
} else {
|
||||
campaign.firstBattleReport.units = [...(campaign.firstBattleReport.units ?? []), { ...target, hp: 0 }];
|
||||
}
|
||||
campaign.inventory = { ...(campaign.inventory ?? {}) };
|
||||
const salveBefore = 2;
|
||||
campaign.inventory['상처약'] = salveBefore;
|
||||
campaign.selectedSortieUnitIds = (campaign.selectedSortieUnitIds ?? []).filter(
|
||||
(unitId) => unitId !== 'zhuge-liang'
|
||||
);
|
||||
window.localStorage.setItem(key, JSON.stringify(campaign));
|
||||
seeded.push({
|
||||
key,
|
||||
ready: true,
|
||||
hp: target.hp,
|
||||
maxHp: target.maxHp,
|
||||
salveBefore,
|
||||
selected: campaign.selectedSortieUnitIds.includes(target.id)
|
||||
});
|
||||
}
|
||||
return {
|
||||
ready: seeded.length === keys.length && seeded.every((entry) => entry.ready),
|
||||
seeded,
|
||||
salveBefore: seeded.find((entry) => entry.key === keys[0])?.salveBefore ?? null
|
||||
};
|
||||
});
|
||||
assert(
|
||||
requiredCasualtySeed.ready === true &&
|
||||
requiredCasualtySeed.seeded.every((entry) => entry.hp === 0 && entry.maxHp > 0 && entry.selected === false),
|
||||
'Expected current and slot saves to retain a zero-HP required Zhuge Liang outside the saved selection: ' +
|
||||
JSON.stringify(requiredCasualtySeed)
|
||||
);
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await waitForTitle(page);
|
||||
await page.mouse.click(962, 310);
|
||||
await waitForCamp(page);
|
||||
const requiredCasualtyCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
assert(
|
||||
requiredCasualtyCampState?.campaign?.step === 'seventeenth-camp' &&
|
||||
requiredCasualtyCampState?.nextSortieBattleId === 'eighteenth-battle-bowang-ambush' &&
|
||||
requiredCasualtyCampState?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang')?.hp === 0 &&
|
||||
!requiredCasualtyCampState?.selectedSortieUnitIds?.includes('zhuge-liang'),
|
||||
'Expected campaign loading to preserve the casualty while excluding it from the active sortie: ' +
|
||||
JSON.stringify(requiredCasualtyCampState)
|
||||
);
|
||||
|
||||
const requiredCasualtyStoryPoint = await readCampStaticTextControlPoint(page, '다음 이야기');
|
||||
await page.mouse.click(requiredCasualtyStoryPoint.x, requiredCasualtyStoryPoint.y);
|
||||
await waitForSortiePrep(page);
|
||||
await advanceSortiePrepStep(page, 'formation');
|
||||
const requiredCasualtyFormationState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
const requiredCasualtyPortrait = requiredCasualtyFormationState?.sortiePortraitRoster?.find(
|
||||
(unit) => unit.id === 'zhuge-liang'
|
||||
);
|
||||
assert(
|
||||
requiredCasualtyPortrait?.required === true &&
|
||||
requiredCasualtyPortrait.selected === false &&
|
||||
requiredCasualtyPortrait.available === false &&
|
||||
requiredCasualtyPortrait.hp === 0 &&
|
||||
requiredCasualtyPortrait.maxHp > 0 &&
|
||||
requiredCasualtyPortrait.availabilityLabel === '부상' &&
|
||||
requiredCasualtyPortrait.availabilityReason?.includes('보급 화면') &&
|
||||
requiredCasualtyFormationState?.sortiePortraitRosterView?.visibleUnitIds?.includes('zhuge-liang') &&
|
||||
requiredCasualtyFormationState?.sortiePrepSteps?.find((step) => step.id === 'formation')?.complete === false,
|
||||
'Expected the unavailable required officer to stay visible, disabled, and formation-incomplete: ' +
|
||||
JSON.stringify({
|
||||
portrait: requiredCasualtyPortrait,
|
||||
view: requiredCasualtyFormationState?.sortiePortraitRosterView,
|
||||
steps: requiredCasualtyFormationState?.sortiePrepSteps
|
||||
})
|
||||
);
|
||||
await page.screenshot({ path: screenshotDir + '/rc-required-sortie-casualty-card.png', fullPage: true });
|
||||
await assertCanvasPainted(page, 'required sortie casualty card');
|
||||
|
||||
await advanceSortiePrepStep(page, 'loadout');
|
||||
const requiredCasualtyLoadoutState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
const requiredCasualtyReadiness = requiredCasualtyLoadoutState?.sortieChecklist?.find(
|
||||
(item) => item.label?.includes('유비') && item.label?.includes('제갈량') && item.label?.includes('생존')
|
||||
);
|
||||
const requiredCasualtyComposition = requiredCasualtyLoadoutState?.sortieChecklist?.find(
|
||||
(item) => item.label === '출전 구성'
|
||||
);
|
||||
const requiredCasualtyRecoveryTarget = requiredCasualtyLoadoutState?.sortieChecklist?.find(
|
||||
(item) => item.label === '부상 장수 확인'
|
||||
);
|
||||
assert(
|
||||
requiredCasualtyReadiness?.complete === false &&
|
||||
requiredCasualtyReadiness.detail?.includes('제갈량 0/') &&
|
||||
requiredCasualtyReadiness.detail?.includes('회복 필요') &&
|
||||
requiredCasualtyComposition?.complete === false &&
|
||||
requiredCasualtyRecoveryTarget?.complete === false &&
|
||||
requiredCasualtyRecoveryTarget.detail?.includes('제갈량 0/') &&
|
||||
requiredCasualtyLoadoutState?.sortieChecklistSummary?.requiredComplete === false &&
|
||||
requiredCasualtyLoadoutState?.sortieChecklistSummary?.requiredRemainingCount >= 2 &&
|
||||
requiredCasualtyLoadoutState?.sortiePrepSteps?.find((step) => step.id === 'loadout')?.complete === false,
|
||||
'Expected the casualty to keep both required final checks incomplete: ' +
|
||||
JSON.stringify({
|
||||
readiness: requiredCasualtyReadiness,
|
||||
composition: requiredCasualtyComposition,
|
||||
recoveryTarget: requiredCasualtyRecoveryTarget,
|
||||
summary: requiredCasualtyLoadoutState?.sortieChecklistSummary,
|
||||
steps: requiredCasualtyLoadoutState?.sortiePrepSteps
|
||||
})
|
||||
);
|
||||
|
||||
const requiredCasualtySaveBeforeLaunch = await readCampaignSave(page);
|
||||
const blockedLaunchPoint = await readSortieTextControlPoint(page, '출진');
|
||||
await page.mouse.click(blockedLaunchPoint.x, blockedLaunchPoint.y);
|
||||
await page.waitForTimeout(120);
|
||||
const blockedRequiredLaunch = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
const state = window.__HEROS_DEBUG__?.camp();
|
||||
return {
|
||||
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
|
||||
sortieVisible: state?.sortieVisible ?? false,
|
||||
sortiePrepStep: state?.sortiePrepStep ?? null,
|
||||
notice: (scene?.dialogueObjects ?? [])
|
||||
.filter((object) => object?.type === 'Text' && object?.active && object?.visible)
|
||||
.map((object) => object.text)
|
||||
.join(' ')
|
||||
};
|
||||
});
|
||||
const requiredCasualtySaveAfterLaunch = await readCampaignSave(page);
|
||||
assert(
|
||||
blockedRequiredLaunch.activeScenes.includes('CampScene') &&
|
||||
!blockedRequiredLaunch.activeScenes.includes('StoryScene') &&
|
||||
!blockedRequiredLaunch.activeScenes.includes('BattleScene') &&
|
||||
blockedRequiredLaunch.sortieVisible === true &&
|
||||
blockedRequiredLaunch.sortiePrepStep === 'loadout' &&
|
||||
blockedRequiredLaunch.notice.includes('제갈량') &&
|
||||
blockedRequiredLaunch.notice.includes('보급 화면') &&
|
||||
sameJsonValue(requiredCasualtySaveAfterLaunch, requiredCasualtySaveBeforeLaunch),
|
||||
'Expected the real launch control to block a zero-HP required officer without mutating saves: ' +
|
||||
JSON.stringify({
|
||||
blockedRequiredLaunch,
|
||||
saveUnchanged: sameJsonValue(requiredCasualtySaveAfterLaunch, requiredCasualtySaveBeforeLaunch)
|
||||
})
|
||||
);
|
||||
await page.screenshot({ path: screenshotDir + '/rc-required-sortie-casualty-blocked.png', fullPage: true });
|
||||
await assertCanvasPainted(page, 'required sortie casualty blocked');
|
||||
|
||||
const returnToCampPoint = await readSortieTextControlPoint(page, '군영으로');
|
||||
await page.mouse.click(returnToCampPoint.x, returnToCampPoint.y);
|
||||
await page.waitForFunction(
|
||||
() => window.__HEROS_DEBUG__?.camp()?.sortieVisible === false,
|
||||
undefined,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await selectCampRosterUnit(page, 'zhuge-liang');
|
||||
const suppliesTabPoint = await readCampTabControlPoint(page, 'supplies');
|
||||
await page.mouse.click(suppliesTabPoint.x, suppliesTabPoint.y);
|
||||
await page.waitForFunction(
|
||||
() => window.__HEROS_DEBUG__?.camp()?.activeTab === 'supplies',
|
||||
undefined,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
const salveUsePoint = await readCampSupplyUseControlPoint(page, '상처약');
|
||||
await page.mouse.click(salveUsePoint.x, salveUsePoint.y);
|
||||
await page.waitForTimeout(120);
|
||||
const salveClickApplied = await page.evaluate(() => {
|
||||
const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang');
|
||||
return target?.hp > 0;
|
||||
});
|
||||
if (!salveClickApplied) {
|
||||
const salveFallback = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang');
|
||||
const texts = (scene?.contentObjects ?? []).filter(
|
||||
(object) => object?.type === 'Text' && object?.active && object?.visible
|
||||
);
|
||||
const supplyTitle = texts.find((object) => object.text?.startsWith('상처약 x'));
|
||||
const useText = texts
|
||||
.filter((object) => object.text === '사용' && object.input?.enabled)
|
||||
.sort((left, right) => Math.abs(left.y - (supplyTitle?.y ?? 0)) - Math.abs(right.y - (supplyTitle?.y ?? 0)))[0];
|
||||
if (!scene || !target || !supplyTitle || !useText) {
|
||||
return {
|
||||
ready: false,
|
||||
targetHp: target?.hp ?? null,
|
||||
supplyTitle: supplyTitle?.text ?? null,
|
||||
useCount: texts.filter((object) => object.text === '사용').length
|
||||
};
|
||||
}
|
||||
useText.emit('pointerdown');
|
||||
return { ready: true, targetHp: target.hp, supplyTitle: supplyTitle.text };
|
||||
});
|
||||
assert(
|
||||
salveFallback.ready === true,
|
||||
'Expected the visible salve control to expose its Phaser action after the canvas click fallback: ' +
|
||||
JSON.stringify(salveFallback)
|
||||
);
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang');
|
||||
return target?.hp > 0;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
const requiredCasualtyRecoverySave = await readCampaignSave(page);
|
||||
const recoveredCurrent = requiredCasualtyRecoverySave.current?.roster?.find((unit) => unit.id === 'zhuge-liang');
|
||||
const recoveredSlot = requiredCasualtyRecoverySave.slot1?.roster?.find((unit) => unit.id === 'zhuge-liang');
|
||||
const recoveredCurrentReport = requiredCasualtyRecoverySave.current?.firstBattleReport?.units?.find(
|
||||
(unit) => unit.id === 'zhuge-liang'
|
||||
);
|
||||
const recoveredSlotReport = requiredCasualtyRecoverySave.slot1?.firstBattleReport?.units?.find(
|
||||
(unit) => unit.id === 'zhuge-liang'
|
||||
);
|
||||
assert(
|
||||
recoveredCurrent?.hp > 0 &&
|
||||
recoveredSlot?.hp === recoveredCurrent.hp &&
|
||||
recoveredCurrentReport?.hp === recoveredCurrent.hp &&
|
||||
recoveredSlotReport?.hp === recoveredCurrent.hp &&
|
||||
(requiredCasualtyRecoverySave.current?.inventory?.['상처약'] ?? 0) === requiredCasualtySeed.salveBefore - 1 &&
|
||||
(requiredCasualtyRecoverySave.slot1?.inventory?.['상처약'] ?? 0) === requiredCasualtySeed.salveBefore - 1,
|
||||
'Expected one real salve use to recover and persist the required officer exactly once: ' +
|
||||
JSON.stringify({
|
||||
currentHp: recoveredCurrent?.hp,
|
||||
slotHp: recoveredSlot?.hp,
|
||||
currentReportHp: recoveredCurrentReport?.hp,
|
||||
slotReportHp: recoveredSlotReport?.hp,
|
||||
currentSalves: requiredCasualtyRecoverySave.current?.inventory?.['상처약'] ?? 0,
|
||||
slotSalves: requiredCasualtyRecoverySave.slot1?.inventory?.['상처약'] ?? 0,
|
||||
expectedSalves: requiredCasualtySeed.salveBefore - 1
|
||||
})
|
||||
);
|
||||
|
||||
const recoveredStoryPoint = await readCampStaticTextControlPoint(page, '다음 이야기');
|
||||
await page.mouse.click(recoveredStoryPoint.x, recoveredStoryPoint.y);
|
||||
await waitForSortiePrep(page);
|
||||
const recoveredRequiredBriefing = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
assert(
|
||||
recoveredRequiredBriefing?.selectedSortieUnitIds?.includes('liu-bei') &&
|
||||
recoveredRequiredBriefing?.selectedSortieUnitIds?.includes('zhuge-liang'),
|
||||
'Expected reopening sortie prep to auto-select every recovered required officer: ' +
|
||||
JSON.stringify(recoveredRequiredBriefing?.selectedSortieUnitIds)
|
||||
);
|
||||
await advanceSortiePrepStep(page, 'formation');
|
||||
const recoveredRequiredFormation = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
const recoveredRequiredPortrait = recoveredRequiredFormation?.sortiePortraitRoster?.find(
|
||||
(unit) => unit.id === 'zhuge-liang'
|
||||
);
|
||||
assert(
|
||||
recoveredRequiredPortrait?.required === true &&
|
||||
recoveredRequiredPortrait.selected === true &&
|
||||
recoveredRequiredPortrait.available === true &&
|
||||
recoveredRequiredPortrait.hp === recoveredCurrent?.hp &&
|
||||
recoveredRequiredFormation?.sortiePrepSteps?.find((step) => step.id === 'formation')?.complete === true,
|
||||
'Expected the recovered required portrait and formation stage to become ready: ' +
|
||||
JSON.stringify({
|
||||
portrait: recoveredRequiredPortrait,
|
||||
steps: recoveredRequiredFormation?.sortiePrepSteps
|
||||
})
|
||||
);
|
||||
await page.screenshot({ path: screenshotDir + '/rc-required-sortie-casualty-recovered.png', fullPage: true });
|
||||
await assertCanvasPainted(page, 'required sortie casualty recovered');
|
||||
|
||||
await advanceSortiePrepStep(page, 'loadout');
|
||||
const recoveredRequiredLoadout = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
const recoveredReadiness = recoveredRequiredLoadout?.sortieChecklist?.find(
|
||||
(item) => item.label?.includes('유비') && item.label?.includes('제갈량') && item.label?.includes('생존')
|
||||
);
|
||||
const recoveredComposition = recoveredRequiredLoadout?.sortieChecklist?.find(
|
||||
(item) => item.label === '출전 구성'
|
||||
);
|
||||
const recoveredRecoveryTarget = recoveredRequiredLoadout?.sortieChecklist?.find(
|
||||
(item) => item.label === '부상 장수 확인'
|
||||
);
|
||||
const recoveredDeploymentIds = recoveredRequiredLoadout?.sortieDeploymentPreview?.map((slot) => slot.unitId) ?? [];
|
||||
assert(
|
||||
recoveredReadiness?.complete === true &&
|
||||
recoveredComposition?.complete === true &&
|
||||
recoveredRecoveryTarget?.complete === true &&
|
||||
recoveredRequiredLoadout?.sortieChecklistSummary?.requiredComplete === true &&
|
||||
recoveredDeploymentIds.filter((unitId) => unitId === 'liu-bei').length === 1 &&
|
||||
recoveredDeploymentIds.filter((unitId) => unitId === 'zhuge-liang').length === 1 &&
|
||||
new Set(recoveredDeploymentIds).size === recoveredDeploymentIds.length,
|
||||
'Expected recovery to complete required checks and produce one unique slot per required officer: ' +
|
||||
JSON.stringify({
|
||||
recoveredReadiness,
|
||||
recoveredComposition,
|
||||
recoveredRecoveryTarget,
|
||||
summary: recoveredRequiredLoadout?.sortieChecklistSummary,
|
||||
deployment: recoveredRequiredLoadout?.sortieDeploymentPreview
|
||||
})
|
||||
);
|
||||
|
||||
const recoveredLaunchPoint = await readSortieTextControlPoint(page, '출진');
|
||||
await page.mouse.click(recoveredLaunchPoint.x, recoveredLaunchPoint.y);
|
||||
await page.waitForFunction(
|
||||
() => (window.__HEROS_DEBUG__?.activeScenes() ?? []).includes('StoryScene'),
|
||||
undefined,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
const recoveredRequiredLaunchSave = await readCampaignSave(page);
|
||||
assert(
|
||||
recoveredRequiredLaunchSave.current?.step === 'eighteenth-battle' &&
|
||||
recoveredRequiredLaunchSave.slot1?.step === 'eighteenth-battle' &&
|
||||
recoveredRequiredLaunchSave.current?.selectedSortieUnitIds?.includes('liu-bei') &&
|
||||
recoveredRequiredLaunchSave.current?.selectedSortieUnitIds?.includes('zhuge-liang'),
|
||||
'Expected the same real launch control to continue after the required officer recovers: ' +
|
||||
JSON.stringify({
|
||||
currentStep: recoveredRequiredLaunchSave.current?.step,
|
||||
slotStep: recoveredRequiredLaunchSave.slot1?.step,
|
||||
selected: recoveredRequiredLaunchSave.current?.selectedSortieUnitIds
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
await page.evaluate((save) => {
|
||||
const write = (key, value) => {
|
||||
if (value === undefined) {
|
||||
window.localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
};
|
||||
write('heros-web:campaign-state', save.current);
|
||||
write('heros-web:campaign-state:slot-1', save.slot1);
|
||||
}, requiredCasualtySaveBefore);
|
||||
const requiredCasualtyRestoredSave = await readCampaignSave(page);
|
||||
assert(
|
||||
sameJsonValue(requiredCasualtyRestoredSave, requiredCasualtySaveBefore),
|
||||
'Expected the required-casualty fixture to restore both campaign saves: ' +
|
||||
JSON.stringify({ before: requiredCasualtySaveBefore, after: requiredCasualtyRestoredSave })
|
||||
);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await waitForTitle(page);
|
||||
}
|
||||
|
||||
await seedCampaignSave(page, {
|
||||
battleId: 'fifty-eighth-battle-qishan-retreat',
|
||||
battleTitle: '기산 후퇴로',
|
||||
@@ -5909,6 +6284,43 @@ async function readCampRosterRowPoint(page, unitId) {
|
||||
return probe;
|
||||
}
|
||||
|
||||
async function selectCampRosterUnit(page, unitId) {
|
||||
const initial = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.campRoster ?? null);
|
||||
const maxAttempts = Math.max(1, initial?.pageCount ?? 1);
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const state = await page.evaluate(() => {
|
||||
const camp = window.__HEROS_DEBUG__?.camp();
|
||||
return {
|
||||
selectedUnitId: camp?.selectedUnitId ?? null,
|
||||
roster: camp?.campRoster ?? null
|
||||
};
|
||||
});
|
||||
if (state.roster?.visibleUnitIds?.includes(unitId)) {
|
||||
const point = await readCampRosterRowPoint(page, unitId);
|
||||
await page.mouse.click(point.x, point.y);
|
||||
await page.waitForFunction(
|
||||
(targetUnitId) => window.__HEROS_DEBUG__?.camp()?.selectedUnitId === targetUnitId,
|
||||
unitId,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
return page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
}
|
||||
if (!state.roster?.nextEnabled || !state.selectedUnitId) {
|
||||
break;
|
||||
}
|
||||
await navigateCampRosterPage(page, 'next', state.roster.page + 1, state.selectedUnitId);
|
||||
}
|
||||
const finalState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
throw new Error(
|
||||
'Could not find camp roster unit ' +
|
||||
unitId +
|
||||
' across ' +
|
||||
maxAttempts +
|
||||
' page(s): ' +
|
||||
JSON.stringify(finalState?.campRoster)
|
||||
);
|
||||
}
|
||||
|
||||
function isPositiveBounds(bounds) {
|
||||
return Boolean(
|
||||
bounds &&
|
||||
@@ -6276,6 +6688,108 @@ async function readCampReportFormationHistoryControlPoint(page, control) {
|
||||
return probe;
|
||||
}
|
||||
|
||||
async function readCampStaticTextControlPoint(page, label) {
|
||||
const probe = await page.evaluate((targetLabel) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
const canvas = document.querySelector('canvas');
|
||||
const target = scene?.children?.list
|
||||
?.filter(
|
||||
(object) =>
|
||||
object?.type === 'Text' &&
|
||||
object.text === targetLabel &&
|
||||
object.active &&
|
||||
object.visible &&
|
||||
object.input?.enabled
|
||||
)
|
||||
.sort((left, right) => left.y - right.y || left.x - right.x)[0];
|
||||
if (!scene || !canvas || !target) {
|
||||
return { ready: false, label: targetLabel };
|
||||
}
|
||||
const bounds = target.getBounds();
|
||||
const canvasBounds = canvas.getBoundingClientRect();
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
return {
|
||||
ready: true,
|
||||
label: targetLabel,
|
||||
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
|
||||
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
||||
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
||||
};
|
||||
}, label);
|
||||
assert(
|
||||
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
||||
'Expected a visible interactive camp command for ' + label + ': ' + JSON.stringify(probe)
|
||||
);
|
||||
return probe;
|
||||
}
|
||||
|
||||
async function readCampTabControlPoint(page, tab) {
|
||||
const probe = await page.evaluate((targetTab) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
const canvas = document.querySelector('canvas');
|
||||
const button = scene?.tabButtons?.find((candidate) => candidate.tab === targetTab);
|
||||
const target = button?.bg;
|
||||
if (!scene || !canvas || !target?.active || !target.visible || !target.input?.enabled) {
|
||||
return { ready: false, tab: targetTab, availableTabs: scene?.tabButtons?.map((candidate) => candidate.tab) ?? [] };
|
||||
}
|
||||
const bounds = target.getBounds();
|
||||
const canvasBounds = canvas.getBoundingClientRect();
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
return {
|
||||
ready: true,
|
||||
tab: targetTab,
|
||||
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
|
||||
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
||||
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
||||
};
|
||||
}, tab);
|
||||
assert(
|
||||
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
||||
'Expected a visible camp tab for ' + tab + ': ' + JSON.stringify(probe)
|
||||
);
|
||||
return probe;
|
||||
}
|
||||
|
||||
async function readSortieTextControlPoint(page, label) {
|
||||
const probe = await page.evaluate((targetLabel) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
const canvas = document.querySelector('canvas');
|
||||
const candidates = (scene?.sortieObjects ?? [])
|
||||
.filter(
|
||||
(object) =>
|
||||
object?.type === 'Text' &&
|
||||
object.text === targetLabel &&
|
||||
object.active &&
|
||||
object.visible &&
|
||||
object.input?.enabled
|
||||
)
|
||||
.sort((left, right) => left.y - right.y || left.x - right.x);
|
||||
const target = candidates[0];
|
||||
if (!scene || !canvas || !target) {
|
||||
return { ready: false, label: targetLabel, candidateCount: candidates.length };
|
||||
}
|
||||
const bounds = target.getBounds();
|
||||
const canvasBounds = canvas.getBoundingClientRect();
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
return {
|
||||
ready: true,
|
||||
label: targetLabel,
|
||||
candidateCount: candidates.length,
|
||||
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
|
||||
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
||||
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
||||
};
|
||||
}, label);
|
||||
assert(
|
||||
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
||||
'Expected a visible interactive sortie control for ' + label + ': ' + JSON.stringify(probe)
|
||||
);
|
||||
return probe;
|
||||
}
|
||||
|
||||
async function readCampContentTextControlPoint(page, label, occurrence = 0) {
|
||||
const probe = await page.evaluate(({ label, occurrence }) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
|
||||
@@ -12888,7 +12888,15 @@ export class CampScene extends Phaser.Scene {
|
||||
}
|
||||
if (step === 'formation') {
|
||||
const plan = this.sortiePlanSummary();
|
||||
return plan.selectedCount > 0 && plan.formationCoverageComplete && this.hasCurrentSortieOrderSelection();
|
||||
const requiredCompositionReady = checklist.find(
|
||||
(item) => item.label === '출전 구성' || item.label === '동행 구성'
|
||||
)?.complete ?? true;
|
||||
return (
|
||||
requiredCompositionReady &&
|
||||
plan.selectedCount > 0 &&
|
||||
plan.formationCoverageComplete &&
|
||||
this.hasCurrentSortieOrderSelection()
|
||||
);
|
||||
}
|
||||
return this.firstSortieFinalCheckItems(checklist).every((item) => item.complete);
|
||||
}
|
||||
@@ -13229,8 +13237,12 @@ export class CampScene extends Phaser.Scene {
|
||||
portraitView.frame.setStrokeStyle(2, accentColor, focused ? 0.92 : selected ? 0.78 : recommendation ? 0.62 : 0.42);
|
||||
portraitView.image?.setDisplaySize(70, 70);
|
||||
});
|
||||
const statusLabel = required ? '필수' : selected ? '출전' : pinnedSwapCandidate ? '교체안' : recommendation ? '추천' : '대기';
|
||||
const statusColor = required || recommendation || pinnedSwapCandidate ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf';
|
||||
const statusLabel = !availability.available
|
||||
? required ? `필수 · ${availability.label}` : availability.label
|
||||
: required ? '필수' : selected ? '출전' : pinnedSwapCandidate ? '교체안' : recommendation ? '추천' : '대기';
|
||||
const statusColor = !availability.available
|
||||
? '#ff9d7d'
|
||||
: required || recommendation || pinnedSwapCandidate ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf';
|
||||
const status = this.trackSortie(this.add.text(cardX + cardWidth - 10, cardY + 9, statusLabel, this.textStyle(10, statusColor, true)));
|
||||
status.setOrigin(1, 0);
|
||||
status.setDepth(depth + 2);
|
||||
@@ -14713,6 +14725,11 @@ export class CampScene extends Phaser.Scene {
|
||||
const selected = new Set(this.selectedSortieUnitIds);
|
||||
const order = new Map(allies.map((unit, index) => [unit.id, index]));
|
||||
return [...allies].sort((a, b) => {
|
||||
const aRequired = this.isRequiredSortieUnit(a.id);
|
||||
const bRequired = this.isRequiredSortieUnit(b.id);
|
||||
if (aRequired !== bRequired) {
|
||||
return Number(bRequired) - Number(aRequired);
|
||||
}
|
||||
const aSelected = selected.has(a.id);
|
||||
const bSelected = selected.has(b.id);
|
||||
if (aSelected !== bSelected) {
|
||||
@@ -14728,11 +14745,6 @@ export class CampScene extends Phaser.Scene {
|
||||
if (aRecommended !== bRecommended) {
|
||||
return Number(bRecommended) - Number(aRecommended);
|
||||
}
|
||||
const aRequired = this.isRequiredSortieUnit(a.id);
|
||||
const bRequired = this.isRequiredSortieUnit(b.id);
|
||||
if (aRequired !== bRequired) {
|
||||
return Number(bRequired) - Number(aRequired);
|
||||
}
|
||||
return (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0);
|
||||
});
|
||||
}
|
||||
@@ -16509,6 +16521,13 @@ export class CampScene extends Phaser.Scene {
|
||||
return this.requiredSortieUnitIdsFor(scenario).has(unitId);
|
||||
}
|
||||
|
||||
private requiredSortieRosterUnitsFor(scenario = this.nextSortieScenario()) {
|
||||
const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit]));
|
||||
return [...this.requiredSortieUnitIdsFor(scenario)]
|
||||
.map((unitId) => rosterById.get(unitId))
|
||||
.filter((unit): unit is UnitData => Boolean(unit));
|
||||
}
|
||||
|
||||
private sortieMaxUnits(scenario = this.nextSortieScenario()) {
|
||||
return Math.min(this.nextSortieRule(scenario).maxUnits, this.sortieAllies().length);
|
||||
}
|
||||
@@ -16989,7 +17008,7 @@ export class CampScene extends Phaser.Scene {
|
||||
|
||||
private sortieRecoveryTargets(thresholdRatio = 0.7) {
|
||||
return this.currentUnits()
|
||||
.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && unit.maxHp > 0 && unit.hp / unit.maxHp < thresholdRatio)
|
||||
.filter((unit) => unit.faction === 'ally' && unit.maxHp > 0 && unit.hp / unit.maxHp < thresholdRatio)
|
||||
.sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
@@ -17237,10 +17256,9 @@ export class CampScene extends Phaser.Scene {
|
||||
|
||||
private sortieChecklist(): SortieChecklistItem[] {
|
||||
const units = this.currentUnits().filter((unit) => unit.faction === 'ally');
|
||||
const sortieAllies = this.sortieAllies();
|
||||
const hasBattle = Boolean(this.nextSortieScenario());
|
||||
const requiredUnitIds = [...this.requiredSortieUnitIdsFor()].filter((id) => sortieAllies.some((unit) => unit.id === id));
|
||||
const requiredUnits = requiredUnitIds.map((id) => sortieAllies.find((unit) => unit.id === id)).filter(Boolean) as UnitData[];
|
||||
const requiredUnits = this.requiredSortieRosterUnitsFor();
|
||||
const requiredUnitIds = requiredUnits.map((unit) => unit.id);
|
||||
const requiredNames = requiredUnits.map((unit) => unit.name);
|
||||
const requiredLabelName = requiredNames.length === 0
|
||||
? hasBattle ? '필수 무장' : '필수 동행'
|
||||
@@ -17250,9 +17268,19 @@ export class CampScene extends Phaser.Scene {
|
||||
? `${requiredNames[0]}·${requiredNames[1]}`
|
||||
: `${requiredNames[0]} 외 ${requiredNames.length - 1}명`;
|
||||
const requiredLabel = `${requiredLabelName} ${hasBattle ? '생존' : '참석'}`;
|
||||
const requiredComplete = requiredUnits.length > 0 ? requiredUnits.every((unit) => unit.hp > 0) : true;
|
||||
const requiredComplete = requiredUnits.length > 0
|
||||
? requiredUnits.every((unit) => this.sortieUnitAvailability(unit).available)
|
||||
: true;
|
||||
const requiredDetail = requiredUnits.length > 0
|
||||
? requiredUnits.map((unit) => `${unit.name} ${unit.hp}/${unit.maxHp}`).join(', ')
|
||||
? requiredUnits.map((unit) => {
|
||||
const availability = this.sortieUnitAvailability(unit);
|
||||
const status = availability.available
|
||||
? ''
|
||||
: unit.hp <= 0
|
||||
? ' · 회복 필요'
|
||||
: ` · ${availability.label}`;
|
||||
return `${unit.name} ${unit.hp}/${unit.maxHp}${status}`;
|
||||
}).join(', ')
|
||||
: '필수 무장 없음';
|
||||
const injured = units.filter((unit) => unit.hp > 0 && unit.hp < unit.maxHp);
|
||||
const recoveryTargets = this.sortieRecoveryTargets();
|
||||
@@ -17296,7 +17324,7 @@ export class CampScene extends Phaser.Scene {
|
||||
},
|
||||
{
|
||||
label: hasBattle ? '출전 구성' : '동행 구성',
|
||||
complete: requiredUnitIds.every((id) => selected.some((unit) => unit.id === id)) && selected.length > 0,
|
||||
complete: requiredComplete && requiredUnitIds.every((id) => selected.some((unit) => unit.id === id)) && selected.length > 0,
|
||||
detail: selected.length > 0 ? selected.map((unit) => unit.name).join(', ') : hasBattle ? '출전 무장 선택 필요' : '동행 무장 선택 필요',
|
||||
priority: 'required'
|
||||
},
|
||||
@@ -17376,10 +17404,14 @@ export class CampScene extends Phaser.Scene {
|
||||
}
|
||||
if (!this.isFinalEpilogueFlow(flow) && !this.ensureSortieSelectionSaved()) {
|
||||
const hasBattle = Boolean(flow.nextBattleId);
|
||||
const availableIds = new Set(this.sortieAllies().map((unit) => unit.id));
|
||||
const requiredNames = [...this.requiredSortieUnitIdsFor()]
|
||||
.filter((unitId) => availableIds.has(unitId))
|
||||
.map((unitId) => this.unitName(unitId));
|
||||
const requiredUnits = this.requiredSortieRosterUnitsFor();
|
||||
const unavailableRequired = requiredUnits.find((unit) => !this.sortieUnitAvailability(unit).available);
|
||||
const requiredNames = requiredUnits.map((unit) => unit.name);
|
||||
if (unavailableRequired) {
|
||||
const availability = this.sortieUnitAvailability(unavailableRequired);
|
||||
this.showCampNotice(`${unavailableRequired.name}: ${availability.reason}`);
|
||||
return;
|
||||
}
|
||||
this.showCampNotice(
|
||||
requiredNames.length > 0
|
||||
? `${hasBattle ? '출전' : '동행'}할 무장을 선택하세요. ${requiredNames.join(', ')}는 반드시 포함되어야 합니다.`
|
||||
@@ -20481,8 +20513,12 @@ export class CampScene extends Phaser.Scene {
|
||||
private ensureSortieSelectionSaved() {
|
||||
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.selectedSortieUnitIds);
|
||||
const selected = new Set(this.selectedSortieUnitIds);
|
||||
const required = [...this.requiredSortieUnitIdsFor()].filter((id) => this.sortieAllies().some((unit) => unit.id === id));
|
||||
if (!required.every((id) => selected.has(id)) || this.selectedSortieUnitIds.length === 0) {
|
||||
const requiredUnits = this.requiredSortieRosterUnitsFor();
|
||||
if (
|
||||
requiredUnits.some((unit) => !this.sortieUnitAvailability(unit).available) ||
|
||||
!requiredUnits.every((unit) => selected.has(unit.id)) ||
|
||||
this.selectedSortieUnitIds.length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.persistSortieSelection();
|
||||
@@ -21142,10 +21178,19 @@ export class CampScene extends Phaser.Scene {
|
||||
cardBounds: this.sortiePortraitRosterLayout?.cardBounds.map((bounds) => ({ ...bounds })) ?? []
|
||||
},
|
||||
sortiePortraitRoster: portraitRosterUnits.map((unit) => {
|
||||
const availability = this.sortieUnitAvailability(unit);
|
||||
const portraitKey = campaignPortraitKeysByUnitId[unit.id];
|
||||
const unitPortraitTextureKey = portraitKey ? portraitCardTextureKey(portraitKey) ?? portraitTextureKey(portraitKey) : undefined;
|
||||
return {
|
||||
id: unit.id,
|
||||
name: unit.name,
|
||||
hp: unit.hp,
|
||||
maxHp: unit.maxHp,
|
||||
required: this.isRequiredSortieUnit(unit.id),
|
||||
selected: this.isSortieSelected(unit.id),
|
||||
available: availability.available,
|
||||
availabilityLabel: availability.label,
|
||||
availabilityReason: availability.reason,
|
||||
portraitTextureKey: unitPortraitTextureKey ?? null,
|
||||
portraitReady: Boolean(unitPortraitTextureKey && this.textures.exists(unitPortraitTextureKey))
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user