fix: block sorties with incapacitated required officers

This commit is contained in:
2026-07-12 04:31:19 +09:00
parent a7925985c7
commit c8e8ee4b3a
2 changed files with 580 additions and 21 deletions

View File

@@ -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');

View File

@@ -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))
};