feat: streamline victory debrief flow

This commit is contained in:
2026-07-28 06:48:05 +09:00
parent 5f6490b2ae
commit 7e06ffb9f5
14 changed files with 1224 additions and 439 deletions

View File

@@ -2400,6 +2400,10 @@ try {
});
assert(resultProbe.outcome === 'victory', `Expected forced victory result: ${JSON.stringify(resultProbe)}`);
assert(resultProbe.resultTexts.includes('목표 정산'), `Expected result objective settlement title: ${JSON.stringify(resultProbe.resultTexts)}`);
assert(
['소요 턴', '생존 아군', '격파 적군', '달성 목표'].every((label) => resultProbe.resultTexts.includes(label)),
`Expected the result summary to expose four exact debrief metrics ending with achieved objectives: ${JSON.stringify(resultProbe.resultTexts)}`
);
assert(
['전투 수입', '최고 전공', '획득 물자', '새 전력 · 해금'].every((label) => resultProbe.resultTexts.includes(label)),
`Expected the result reward panel to expose four scannable reward cards: ${JSON.stringify(resultProbe.resultTexts)}`
@@ -2428,6 +2432,12 @@ try {
`Expected the completed victory result to expose one correctly named story CTA: ${JSON.stringify(firstResultState?.resultActions)}`
);
const firstResultSave = await readCampaignSave(page);
const persistedFirstRewards = firstResultSave.current?.firstBattleReport?.campaignRewards;
assertBattleResultDebriefText(
resultProbe.resultTexts,
persistedFirstRewards,
'first battle'
);
assert(
firstResultSave.current?.pendingAftermathBattleId === 'first-battle-zhuo-commandery' &&
firstResultSave.slot1?.pendingAftermathBattleId === 'first-battle-zhuo-commandery',
@@ -2687,8 +2697,26 @@ try {
firstCampSaveAfterAftermath.slot1?.pendingAftermathBattleId === undefined,
`Expected CampScene entry to complete the persisted aftermath exactly once: ${JSON.stringify(firstCampSaveAfterAftermath)}`
);
const firstRewardDisplaysByPage = Object.fromEntries(
firstCampTransition.rewardDisplays.map((display) => [display.pageId, display])
const firstVictoryStoryPageIds = [
'first-victory-village',
'first-victory-liu-bei',
'first-victory-guan-yu',
'first-victory-zhang-fei',
'first-victory-next'
];
assert(
sameJsonValue(
firstCampTransition.rewardDisplays.map((display) => display.pageId),
firstVictoryStoryPageIds
) &&
firstCampTransition.rewardDisplays.every((display) => (
display.source === 'campaign' &&
display.battleId === 'first-battle-zhuo-commandery' &&
display.rewardGold === null &&
Array.isArray(display.items) &&
display.items.length === 0
)),
`Expected all five first-victory aftermath pages to retain campaign context without duplicating exact rewards: ${JSON.stringify(firstCampTransition.rewardDisplays)}`
);
const firstNarrativeMemoriesByPage = Object.fromEntries(
firstCampTransition.narrativeMemories.map((memory) => [memory.pageId, memory])
@@ -2714,49 +2742,9 @@ try {
),
`Expected Guan Yu and Zhang Fei to remember the persisted first command in distinct voices: ${JSON.stringify(firstCampTransition.narrativeMemories)}`
);
const persistedFirstRewards = firstResultSave.current?.firstBattleReport?.campaignRewards;
const expectedFirstStorySupplies = [
...(persistedFirstRewards?.supplies ?? []),
...firstSortieOrderBonus.rewardItems
];
assert(
firstCampTransition.rewardDisplays.length === 5 &&
firstCampTransition.rewardDisplays.every((display) => (
display.source === 'campaign' && display.battleId === 'first-battle-zhuo-commandery'
)) &&
firstRewardDisplaysByPage['first-victory-village']?.rewardGold === expectedFirstDisplayedRewardGold &&
sameJsonValue(
firstRewardDisplaysByPage['first-victory-village']?.items.filter((item) => item.category === 'supply').map((item) => item.label),
expectedFirstStorySupplies
) &&
sameJsonValue(
firstRewardDisplaysByPage['first-victory-liu-bei']?.items.filter((item) => item.category === 'equipment').map((item) => item.label),
persistedFirstRewards?.equipment
) &&
sameJsonValue(
firstRewardDisplaysByPage['first-victory-liu-bei']?.items.filter((item) => item.category === 'reputation').map((item) => item.label),
persistedFirstRewards?.reputation
) &&
sameJsonValue(
[
...(firstRewardDisplaysByPage['first-victory-guan-yu']?.items ?? []),
...(firstRewardDisplaysByPage['first-victory-zhang-fei']?.items ?? [])
].filter((item) => item.category === 'recruit').map((item) => item.id.replace('recruit-', '')),
persistedFirstRewards?.recruits.map((recruit) => recruit.unitId)
) &&
sameJsonValue(
firstRewardDisplaysByPage['first-victory-next']?.items.filter((item) => item.category === 'unlock').map((item) => item.id.replace('unlock-', '')),
persistedFirstRewards?.unlocks.map((unlock) => unlock.battleId)
),
`Expected every first-victory story reward card to match the persisted battle report: ${JSON.stringify({ displays: firstCampTransition.rewardDisplays, persistedFirstRewards })}`
);
await waitForCampSkinTransition(page, 'yellow-turban');
await waitForCampSoundscapeTransition(page, 'camp-rally', 'campfire-ambience');
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === true,
undefined,
{ timeout: 30000 }
);
await waitForCampVictoryRewardLedgerReady(page, 'first-battle-zhuo-commandery');
await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true });
await assertCanvasPainted(page, 'first camp');
@@ -2773,32 +2761,52 @@ try {
}
});
assert(firstCampProbe.state?.campaign?.step === 'first-camp', `Expected victory to return to first camp: ${JSON.stringify(firstCampProbe.state)}`);
const firstArrivalReward = firstCampProbe.state?.victoryRewardAcknowledgement;
const firstArrival = firstCampProbe.state?.victorySettlementArrival;
const firstArrivalLedger = firstCampProbe.state?.victoryRewardAcknowledgement;
const firstArrivalSaveBeforeLedger = await readCampaignSave(page);
const firstArrivalAcknowledgements = campaignVictoryAcknowledgementSnapshot(
firstArrivalSaveBeforeLedger,
'first-battle-zhuo-commandery'
);
const firstArrivalNoticeDismissals = campaignVictoryNoticeDismissalSnapshot(
firstArrivalSaveBeforeLedger,
'first-battle-zhuo-commandery'
);
assert(
firstArrivalReward?.visible === true &&
firstArrivalReward.battleId === 'first-battle-zhuo-commandery' &&
firstArrivalReward.cards.length === 6 &&
firstArrivalReward.cards.find((card) => card.id === 'gold')?.items.includes(`+${expectedFirstDisplayedRewardGold} 군자금`) &&
firstArrivalReward.cards.find((card) => card.id === 'supplies')?.items.includes('상처약 1') &&
firstArrivalReward.cards.every((card) => card.iconReady === true) &&
firstArrivalReward.cards.find((card) => card.id === 'equipment')?.iconKind === 'item' &&
firstArrivalReward.cards.find((card) => card.id === 'equipment')?.new === true &&
firstArrivalReward.cards.find((card) => card.id === 'equipment')?.actionId === 'equipment' &&
firstArrivalReward.cards.find((card) => card.id === 'equipment')?.interactive === true &&
firstArrivalReward.cards.find((card) => card.id === 'recruits')?.iconKind === 'portrait' &&
firstArrivalReward.cards.find((card) => card.id === 'recruits')?.new === true &&
firstArrivalReward.cards.find((card) => card.id === 'recruits')?.actionId === 'sortie' &&
firstArrivalReward.cards.find((card) => card.id === 'unlocks')?.new === true &&
firstArrivalReward.cards.find((card) => card.id === 'unlocks')?.actionId === 'sortie' &&
firstArrivalReward.cards.every((card) => boundsWithinFhdViewport(card.bounds)) &&
firstArrivalReward.actions.map((action) => action.id).join(',') === 'equipment,supplies,guided,sortie,close' &&
firstArrivalReward.actions.filter((action) => action.primary).length === 1 &&
firstArrivalReward.actions.find((action) => action.id === 'guided')?.label === '북문 정찰막 둘러보기' &&
firstArrivalReward.actions.find((action) => action.id === 'guided')?.primary === true &&
firstArrivalReward.actions.find((action) => action.id === 'sortie')?.label === '바로 출진' &&
firstArrivalReward.actions.find((action) => action.id === 'sortie')?.primary === false &&
firstArrivalReward.actions.every((action) => action.interactive && boundsWithinFhdViewport(action.bounds)) &&
firstCampProbe.state?.sortieCommand?.label === '정찰막 둘러보기' &&
firstArrival?.battleId === 'first-battle-zhuo-commandery' &&
firstArrival.battleTitle === persistedFirstReport?.battleTitle &&
typeof firstArrival.message === 'string' &&
firstArrival.message.includes(firstArrival.battleTitle) &&
firstArrival.message.includes('보상 장부') &&
sameJsonValue(firstArrival.settledBattleIds, ['first-battle-zhuo-commandery']) &&
sameJsonValue(firstArrival.pendingCategories, ['equipment', 'supplies', 'recruits', 'unlocks']) &&
sameJsonValue(firstArrival.passiveCategoriesAcknowledged, ['gold', 'reputation']) &&
firstArrival.blocking === false &&
firstArrivalNoticeDismissals.current === true &&
firstArrivalNoticeDismissals.slot1 === true &&
['current', 'slot1'].every((saveKey) => (
firstArrivalAcknowledgements[saveKey].battleAcknowledged === false &&
sameJsonValue(firstArrivalAcknowledgements[saveKey].categories, ['gold', 'reputation'])
)),
`Expected first-camp arrival to settle the notice nonblockingly, acknowledge only passive rewards, and leave destination rewards NEW: ${JSON.stringify({
arrival: firstArrival,
acknowledgements: firstArrivalAcknowledgements,
noticeDismissals: firstArrivalNoticeDismissals
})}`
);
assert(
firstArrivalLedger?.role === 'on-demand-ledger' &&
firstArrivalLedger.available === true &&
firstArrivalLedger.visible === false &&
firstArrivalLedger.battleId === 'first-battle-zhuo-commandery' &&
boundsWithinFhdViewport(firstArrivalLedger.toggleButtonBounds) &&
firstArrivalLedger.toggleInteractive === true &&
firstArrivalLedger.cards.length === 0 &&
firstArrivalLedger.actions.length === 0,
`Expected first-camp rewards to remain closed behind one FHD-safe on-demand ledger button: ${JSON.stringify(firstArrivalLedger)}`
);
assert(
firstCampProbe.state?.sortieCommand?.label === '정찰막 둘러보기' &&
firstCampProbe.state?.sortieCommand?.guidedKind === 'visit' &&
firstCampProbe.state?.sortieCommand?.targetId === firstPursuitScoutVisitId &&
firstCampProbe.state?.sortieCommand?.interactive === true &&
@@ -2819,8 +2827,7 @@ try {
firstCampProbe.state?.campTabs?.find((tab) => tab.id === 'visit')?.visualState === 'active' &&
firstCampProbe.state?.campTabs?.find((tab) => tab.id === 'visit')?.newBadgeVisible === true &&
firstCampProbe.state?.campTabs?.filter((tab) => ['supplies', 'equipment'].includes(tab.id)).every((tab) => tab.newBadgeVisible === true),
`Expected first camp arrival to expose bounded reward cards, deep links, and NEW badges: ${JSON.stringify({
reward: firstArrivalReward,
`Expected first camp arrival to preserve its guided next action, recruit history, and destination NEW badges without a blocking modal: ${JSON.stringify({
sortieCommand: firstCampProbe.state?.sortieCommand,
activeTab: firstCampProbe.state?.activeTab,
selectedVisitId: firstCampProbe.state?.selectedVisitId,
@@ -2828,6 +2835,79 @@ try {
sortieRoster: firstCampProbe.state?.sortieRoster
})}`
);
const firstArrivalLedgerTogglePoint = await readCampVictoryRewardLedgerTogglePoint(page);
await page.mouse.click(firstArrivalLedgerTogglePoint.x, firstArrivalLedgerTogglePoint.y);
await page.waitForFunction(() => {
const ledger = window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement;
return ledger?.visible === true && ledger.cards?.length === 6 && ledger.actions?.length === 4;
}, undefined, { timeout: 30000 });
const firstArrivalReward = await page.evaluate(
() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement
);
const firstArrivalCardsById = Object.fromEntries(
firstArrivalReward.cards.map((card) => [card.id, card])
);
const expectedFirstLedgerSupplies = [
...(persistedFirstRewards?.supplies ?? []),
...firstSortieOrderBonus.rewardItems
];
assert(
firstArrivalReward.role === 'on-demand-ledger' &&
firstArrivalReward.available === true &&
firstArrivalReward.visible === true &&
firstArrivalReward.battleId === 'first-battle-zhuo-commandery' &&
sameJsonValue(
firstArrivalReward.cards.map((card) => card.id),
['gold', 'equipment', 'supplies', 'reputation', 'recruits', 'unlocks']
) &&
firstArrivalCardsById.gold?.items[0] === `+${expectedFirstDisplayedRewardGold} 군자금` &&
(
firstSortieOrderBonus.rewardGold === 0 ||
firstArrivalCardsById.gold?.items[1]?.includes(`+${firstSortieOrderBonus.rewardGold}`)
) &&
sameJsonValue(firstArrivalCardsById.equipment?.items, persistedFirstRewards?.equipment ?? []) &&
sameJsonValue(firstArrivalCardsById.supplies?.items, expectedFirstLedgerSupplies) &&
sameJsonValue(firstArrivalCardsById.reputation?.items, persistedFirstRewards?.reputation ?? []) &&
sameJsonValue(
firstArrivalCardsById.recruits?.items,
(persistedFirstRewards?.recruits ?? []).map((recruit) => recruit.name)
) &&
sameJsonValue(
firstArrivalCardsById.unlocks?.items,
(persistedFirstRewards?.unlocks ?? []).map((unlock) => unlock.title)
) &&
sameJsonValue(
firstArrivalReward.cards.map((card) => card.iconKind),
['mark', 'item', 'battle-ui', 'mark', 'portrait', 'battle-ui']
) &&
sameJsonValue(
firstArrivalReward.cards.filter((card) => card.new).map((card) => card.id),
['equipment', 'supplies', 'recruits', 'unlocks']
) &&
sameJsonValue(
firstArrivalReward.cards.map((card) => ({ id: card.id, actionId: card.actionId })),
[
{ id: 'gold', actionId: null },
{ id: 'equipment', actionId: 'equipment' },
{ id: 'supplies', actionId: 'supplies' },
{ id: 'reputation', actionId: null },
{ id: 'recruits', actionId: 'sortie' },
{ id: 'unlocks', actionId: 'sortie' }
]
) &&
firstArrivalReward.cards.every((card) => (
card.iconReady === true &&
card.interactive === ['equipment', 'supplies', 'recruits', 'unlocks'].includes(card.id) &&
boundsWithinFhdViewport(card.bounds)
)) &&
sameJsonValue(
firstArrivalReward.actions.map((action) => action.id),
['equipment', 'supplies', 'sortie', 'close']
) &&
firstArrivalReward.actions.filter((action) => action.primary).map((action) => action.id).join(',') === 'close' &&
firstArrivalReward.actions.every((action) => action.interactive && boundsWithinFhdViewport(action.bounds)),
`Expected the requested first-camp ledger to expose six exact reward cards and four actions with close as the sole primary: ${JSON.stringify(firstArrivalReward)}`
);
const firstArrivalEquipmentPoint = await readCampVictoryRewardCardControlPoint(page, 'equipment');
await page.mouse.click(firstArrivalEquipmentPoint.x, firstArrivalEquipmentPoint.y);
await page.waitForFunction(() => {
@@ -2836,23 +2916,67 @@ try {
}, undefined, { timeout: 30000 });
const acknowledgedFirstArrival = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const acknowledgedFirstArrivalSave = await readCampaignSave(page);
const acknowledgedFirstArrivalCategories = campaignVictoryAcknowledgementSnapshot(
acknowledgedFirstArrivalSave,
'first-battle-zhuo-commandery'
);
assert(
!acknowledgedFirstArrivalSave.current?.acknowledgedVictoryRewardBattleIds?.includes('first-battle-zhuo-commandery') &&
!acknowledgedFirstArrivalSave.slot1?.acknowledgedVictoryRewardBattleIds?.includes('first-battle-zhuo-commandery') &&
acknowledgedFirstArrivalSave.current?.acknowledgedVictoryRewardCategories?.['first-battle-zhuo-commandery']?.includes('equipment') &&
acknowledgedFirstArrivalSave.slot1?.acknowledgedVictoryRewardCategories?.['first-battle-zhuo-commandery']?.includes('equipment') &&
sameJsonValue(
campaignSaveWithoutVictoryRewardAcknowledgementEffects(acknowledgedFirstArrivalSave),
campaignSaveWithoutVictoryRewardAcknowledgementEffects(firstArrivalSaveBeforeLedger)
) &&
['current', 'slot1'].every((saveKey) => (
acknowledgedFirstArrivalCategories[saveKey].battleAcknowledged === false &&
sameJsonValue(
acknowledgedFirstArrivalCategories[saveKey].categories,
['gold', 'equipment', 'reputation']
)
)) &&
campaignVictoryNoticeDismissalSnapshot(
acknowledgedFirstArrivalSave,
'first-battle-zhuo-commandery'
).current === true &&
campaignVictoryNoticeDismissalSnapshot(
acknowledgedFirstArrivalSave,
'first-battle-zhuo-commandery'
).slot1 === true &&
acknowledgedFirstArrival.campTabs.find((tab) => tab.id === 'equipment')?.newBadgeVisible === false &&
acknowledgedFirstArrival.campTabs.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true,
`Expected an equipment deep link to acknowledge only equipment while preserving other reward NEW states: ${JSON.stringify({ acknowledgedFirstArrival, acknowledgedFirstArrivalSave })}`
`Expected an equipment ledger deep link to add only equipment acknowledgement while preserving passive settlement, other NEW states, and all unrelated save data: ${JSON.stringify({
before: firstArrivalAcknowledgements,
after: acknowledgedFirstArrivalCategories,
state: acknowledgedFirstArrival
})}`
);
await clickLegacyUi(page, 576, 38);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.activeTab === 'status', undefined, { timeout: 30000 });
firstCampProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const reportBounds = scene?.reportPanelBackground?.getBounds?.();
const texts = textValues(scene?.contentObjects);
return {
state: window.__HEROS_DEBUG__?.camp(),
summary: scene?.reportSummary?.(),
texts: textValues(scene?.contentObjects)
texts,
reportTexts: reportBounds
? (scene?.contentObjects ?? [])
.filter((object) => object?.type === 'Text')
.filter((object) => {
const bounds = object.getBounds?.();
if (!bounds) {
return false;
}
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return (
centerX >= reportBounds.x &&
centerX <= reportBounds.x + reportBounds.width &&
centerY >= reportBounds.y &&
centerY <= reportBounds.y + reportBounds.height
);
})
.map((object) => object.text)
: []
};
function textValues(objects = []) {
@@ -2909,17 +3033,33 @@ try {
`Expected first camp deployment preview to avoid duplicate tiles: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}`
);
assert(
firstCampProbe.summary?.includes('군자금') && !firstCampProbe.summary.includes('\n'),
`Expected the compact camp summary to show held gold on one line: ${JSON.stringify(firstCampProbe)}`
firstCampProbe.summary?.includes('군영 준비 중') &&
!firstCampProbe.summary.includes('군자금') &&
!firstCampProbe.summary.includes('\n'),
`Expected the compact camp summary to show readiness on one line without repeating an exact reward amount: ${JSON.stringify(firstCampProbe)}`
);
assert(firstCampProbe.texts.some((text) => text.includes('전투 보상')), `Expected camp report to show battle reward: ${JSON.stringify(firstCampProbe.texts)}`);
const firstExactRewardLabels = [
`+${expectedFirstDisplayedRewardGold} 군자금`,
...(persistedFirstRewards?.supplies ?? []),
...firstSortieOrderBonus.rewardItems,
...(persistedFirstRewards?.equipment ?? []),
...(persistedFirstRewards?.reputation ?? []),
...(persistedFirstRewards?.recruits ?? []).map((recruit) => recruit.name),
...(persistedFirstRewards?.unlocks ?? []).map((unlock) => unlock.title)
];
assert(
firstCampProbe.texts.some((text) => text.includes('명령 기록') && text.includes('재정비')),
`Expected the camp report to show the persisted command record: ${JSON.stringify(firstCampProbe.texts)}`
firstCampProbe.reportTexts.some((text) => text.includes('세부 보상과 전과는 보상 장부에서 확인')) &&
firstExactRewardLabels.every(
(rewardLabel) => !firstCampProbe.reportTexts.some((text) => text.includes(rewardLabel))
),
`Expected the always-on camp report to route exact rewards to the ledger without repeating names, titles, items, or amounts: ${JSON.stringify({
reportTexts: firstCampProbe.reportTexts,
hiddenRewardLabels: firstExactRewardLabels
})}`
);
assert(
firstCampProbe.texts.some((text) => text.includes(`다음 ${firstBattleUnlockLabel}`)),
`Expected camp report to surface first battle unlock label: ${JSON.stringify(firstCampProbe.texts)}`
firstCampProbe.reportTexts.some((text) => text.includes('명령 기록') && text.includes('재정비')),
`Expected the camp report to show the persisted command record: ${JSON.stringify(firstCampProbe.reportTexts)}`
);
assert(
firstCampProbe.state?.report?.campaignRewards?.unlocks?.some(
@@ -3079,15 +3219,22 @@ try {
undefined,
{ timeout: 90000 }
);
await page.waitForTimeout(250);
if (await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement?.visible === true)) {
await page.keyboard.press('Escape');
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement?.visible === false,
undefined,
{ timeout: 30000 }
await page.waitForFunction(() => {
const ledger = window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement;
return (
ledger?.role === 'on-demand-ledger' &&
ledger.available === true &&
ledger.visible === false &&
ledger.toggleInteractive === true
);
}
}, undefined, { timeout: 30000 });
const returnedFirstCampLedger = await page.evaluate(
() => window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement
);
assert(
boundsWithinFhdViewport(returnedFirstCampLedger?.toggleButtonBounds),
`Expected returning from camp exploration to keep the reward ledger closed and available on demand: ${JSON.stringify(returnedFirstCampLedger)}`
);
const returnedFirstCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.());
if (
returnedFirstCampState?.activeTab !== 'visit' ||
@@ -7356,8 +7503,22 @@ try {
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
await waitForBattleOutcome(page, 'victory');
const midResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const midResultProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
return {
state: window.__HEROS_DEBUG__?.battle(),
texts: (scene?.resultObjects ?? [])
.filter((object) => object?.type === 'Text')
.map((object) => object.text)
};
});
const midResultState = midResultProbe.state;
const midResultSaveBeforeUpdate = await readCampaignSave(page);
assertBattleResultDebriefText(
midResultProbe.texts,
midResultSaveBeforeUpdate.current?.firstBattleReport?.campaignRewards,
'mid-campaign battle'
);
const midResultPerformance = midResultState?.sortieEvaluation?.snapshot;
const midResultSortieReviewBeforePresetUpdate = structuredClone(
midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortieReview
@@ -7581,16 +7742,16 @@ try {
midImprovementAftermath.rewardDisplays.length === 0,
`Expected next-sortie improvement to show only the completed battle's aftermath before camp: ${JSON.stringify(midImprovementAftermath)}`
);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === true,
undefined,
{ timeout: 30000 }
);
await waitForCampVictoryRewardLedgerReady(page, midCampNextBattleId);
const pendingMidImprovementReward = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const pendingMidImprovementSave = await readCampaignSave(page);
const midImprovementCampEntrySaveUnchanged = sameJsonValue(
campaignSaveWithoutCampRecruitmentEffects(pendingMidImprovementSave),
campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave)
campaignSaveWithoutVictoryRewardAcknowledgementEffects(
campaignSaveWithoutCampRecruitmentEffects(pendingMidImprovementSave)
),
campaignSaveWithoutVictoryRewardAcknowledgementEffects(
campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave)
)
);
const midImprovementAftermathCompleted =
updatedMobileResultSave.current?.pendingAftermathBattleId === midCampNextBattleId &&
@@ -7601,29 +7762,91 @@ try {
pendingMidImprovementSave,
midCampNextBattleId
);
const midImprovementNoticeDismissals = campaignVictoryNoticeDismissalSnapshot(
pendingMidImprovementSave,
midCampNextBattleId
);
const midImprovementArrival = pendingMidImprovementReward?.victorySettlementArrival;
const midImprovementLedger = pendingMidImprovementReward?.victoryRewardAcknowledgement;
const midImprovementRewardDestinationPending = Object.values(midImprovementRewardAcknowledgements).every(
(entry) => !entry.battleAcknowledged &&
!entry.categories.includes('equipment') &&
!entry.categories.includes('supplies')
(entry) => (
!entry.battleAcknowledged &&
sameJsonValue(entry.categories, midImprovementArrival?.passiveCategoriesAcknowledged ?? []) &&
(midImprovementArrival?.pendingCategories ?? []).every(
(category) => !entry.categories.includes(category)
)
)
);
assert(
pendingMidImprovementReward?.openSortiePrepOnCreate === true &&
pendingMidImprovementReward.sortieVisible === false &&
pendingMidImprovementReward.victoryRewardAcknowledgement?.battleId === midCampNextBattleId &&
midImprovementArrival?.battleId === midCampNextBattleId &&
midImprovementArrival.battleTitle === pendingMidImprovementReward.report?.battleTitle &&
typeof midImprovementArrival.message === 'string' &&
midImprovementArrival.message.includes('보상 장부') &&
sameJsonValue(midImprovementArrival.settledBattleIds, [midCampNextBattleId]) &&
midImprovementArrival.passiveCategoriesAcknowledged.every(
(category) => ['gold', 'reputation'].includes(category)
) &&
midImprovementArrival.pendingCategories.length > 0 &&
midImprovementArrival.pendingCategories.every(
(category) => !['gold', 'reputation'].includes(category)
) &&
midImprovementArrival.blocking === false &&
midImprovementNoticeDismissals.current === true &&
midImprovementNoticeDismissals.slot1 === true &&
midImprovementLedger?.role === 'on-demand-ledger' &&
midImprovementLedger.available === true &&
midImprovementLedger.visible === false &&
midImprovementLedger.battleId === midCampNextBattleId &&
boundsWithinFhdViewport(midImprovementLedger.toggleButtonBounds) &&
midImprovementLedger.toggleInteractive === true &&
midImprovementLedger.cards.length === 0 &&
midImprovementLedger.actions.length === 0 &&
midImprovementCampEntrySaveUnchanged &&
midImprovementAftermathCompleted &&
midImprovementRewardDestinationPending,
`Expected next-sortie improvement to preserve its requested prep while showing the unacknowledged victory reward first: ${JSON.stringify({
`Expected next-sortie improvement arrival to remain nonblocking with passive settlement and an unopened on-demand ledger: ${JSON.stringify({
openSortiePrepOnCreate: pendingMidImprovementReward?.openSortiePrepOnCreate,
sortieVisible: pendingMidImprovementReward?.sortieVisible,
rewardBattleId: pendingMidImprovementReward?.victoryRewardAcknowledgement?.battleId,
arrival: midImprovementArrival,
ledger: midImprovementLedger,
expectedBattleId: midCampNextBattleId,
saveUnchanged: midImprovementCampEntrySaveUnchanged,
aftermathCompleted: midImprovementAftermathCompleted,
rewardDestinationPending: midImprovementRewardDestinationPending,
rewardAcknowledgements: midImprovementRewardAcknowledgements
rewardAcknowledgements: midImprovementRewardAcknowledgements,
noticeDismissals: midImprovementNoticeDismissals
})}`
);
const midImprovementLedgerTogglePoint = await readCampVictoryRewardLedgerTogglePoint(page);
await page.mouse.click(midImprovementLedgerTogglePoint.x, midImprovementLedgerTogglePoint.y);
await page.waitForFunction(() => {
const ledger = window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement;
return ledger?.visible === true && ledger.cards?.length === 6 && ledger.actions?.length === 4;
}, undefined, { timeout: 30000 });
const openedMidImprovementLedger = await page.evaluate(
() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement
);
const openedMidImprovementLedgerSave = await readCampaignSave(page);
assert(
openedMidImprovementLedger?.role === 'on-demand-ledger' &&
openedMidImprovementLedger.available === true &&
openedMidImprovementLedger.battleId === midCampNextBattleId &&
sameJsonValue(
openedMidImprovementLedger.cards.map((card) => card.id),
['gold', 'equipment', 'supplies', 'reputation', 'recruits', 'unlocks']
) &&
sameJsonValue(
openedMidImprovementLedger.actions.map((action) => action.id),
['equipment', 'supplies', 'sortie', 'close']
) &&
openedMidImprovementLedger.actions.filter((action) => action.primary).map((action) => action.id).join(',') === 'close' &&
openedMidImprovementLedger.cards.every((card) => boundsWithinFhdViewport(card.bounds)) &&
openedMidImprovementLedger.actions.every((action) => action.interactive && boundsWithinFhdViewport(action.bounds)) &&
sameJsonValue(openedMidImprovementLedgerSave, pendingMidImprovementSave),
`Expected opening the mid-campaign ledger to reveal the six-card/four-action contract without mutating either save: ${JSON.stringify(openedMidImprovementLedger)}`
);
await page.screenshot({ path: `${screenshotDir}/rc-mid-improvement-victory-reward.png`, fullPage: true });
const midImprovementSortiePoint = await readCampVictoryRewardControlPoint(page, 'sortie');
await page.mouse.click(midImprovementSortiePoint.x, midImprovementSortiePoint.y);
@@ -7909,8 +8132,9 @@ try {
{ timeout: 30000 }
);
const finalBattleId = 'sixty-sixth-battle-wuzhang-final';
await seedCampaignSave(page, {
battleId: 'sixty-sixth-battle-wuzhang-final',
battleId: finalBattleId,
battleTitle: '오장원 최종전',
step: 'sixty-sixth-camp',
gold: 9800,
@@ -7925,12 +8149,17 @@ try {
await waitForCamp(page);
await waitForCampSkinTransition(page, 'northern');
await waitForCampSoundscapeTransition(page, 'camp-frontier', 'mountain-wind-ambience');
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === true,
undefined,
{ timeout: 30000 }
);
await waitForCampVictoryRewardLedgerReady(page, finalBattleId);
const finalCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const finalCampArrivalSave = await readCampaignSave(page);
const finalCampArrivalAcknowledgements = campaignVictoryAcknowledgementSnapshot(
finalCampArrivalSave,
finalBattleId
);
const finalCampArrivalNoticeDismissals = campaignVictoryNoticeDismissalSnapshot(
finalCampArrivalSave,
finalBattleId
);
assert(finalCampState?.campaign?.step === 'sixty-sixth-camp', `Expected final camp save to continue: ${JSON.stringify(finalCampState)}`);
assertCampSkinState(finalCampState, 'northern', 'final camp');
assertCampSoundscapeState(finalCampState, {
@@ -7954,11 +8183,71 @@ try {
`Expected final camp deployment preview to stay empty: ${JSON.stringify(finalCampState?.sortieDeploymentPreview)}`
);
assert(
finalCampState.victoryRewardAcknowledgement?.visible === true &&
finalCampState.victoryRewardAcknowledgement?.battleId === 'sixty-sixth-battle-wuzhang-final',
`Expected the seeded final victory to expose its undismissed reward notice: ${JSON.stringify(finalCampState.victoryRewardAcknowledgement)}`
finalCampState.victorySettlementArrival?.battleId === finalBattleId &&
finalCampState.victorySettlementArrival.battleTitle === '오장원 최종전' &&
finalCampState.victorySettlementArrival.message.includes('보상 장부') &&
sameJsonValue(finalCampState.victorySettlementArrival.settledBattleIds, [finalBattleId]) &&
sameJsonValue(
finalCampState.victorySettlementArrival.pendingCategories,
['equipment', 'supplies']
) &&
sameJsonValue(
finalCampState.victorySettlementArrival.passiveCategoriesAcknowledged,
['gold', 'reputation']
) &&
finalCampState.victorySettlementArrival.blocking === false &&
finalCampArrivalNoticeDismissals.current === true &&
finalCampArrivalNoticeDismissals.slot1 === true &&
['current', 'slot1'].every((saveKey) => (
finalCampArrivalAcknowledgements[saveKey].battleAcknowledged === false &&
sameJsonValue(finalCampArrivalAcknowledgements[saveKey].categories, ['gold', 'reputation'])
)) &&
finalCampState.victoryRewardAcknowledgement?.role === 'on-demand-ledger' &&
finalCampState.victoryRewardAcknowledgement.available === true &&
finalCampState.victoryRewardAcknowledgement.visible === false &&
finalCampState.victoryRewardAcknowledgement.battleId === finalBattleId &&
boundsWithinFhdViewport(finalCampState.victoryRewardAcknowledgement.toggleButtonBounds) &&
finalCampState.victoryRewardAcknowledgement.toggleInteractive === true &&
finalCampState.victoryRewardAcknowledgement.cards.length === 0 &&
finalCampState.victoryRewardAcknowledgement.actions.length === 0 &&
finalCampState.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true &&
finalCampState.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true,
`Expected seeded final victory arrival to settle passives nonblockingly and leave its destination rewards behind an unopened ledger: ${JSON.stringify({
arrival: finalCampState.victorySettlementArrival,
ledger: finalCampState.victoryRewardAcknowledgement,
acknowledgements: finalCampArrivalAcknowledgements,
noticeDismissals: finalCampArrivalNoticeDismissals,
tabs: finalCampState.campTabs
})}`
);
const finalCampLedgerTogglePoint = await readCampVictoryRewardLedgerTogglePoint(page);
await page.mouse.click(finalCampLedgerTogglePoint.x, finalCampLedgerTogglePoint.y);
await page.waitForFunction(() => {
const ledger = window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement;
return ledger?.visible === true && ledger.cards?.length === 6 && ledger.actions?.length === 4;
}, undefined, { timeout: 30000 });
const finalCampOpenedLedger = await page.evaluate(
() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement
);
const finalCampRewardSaveBeforeClose = await readCampaignSave(page);
assert(
finalCampOpenedLedger?.role === 'on-demand-ledger' &&
finalCampOpenedLedger.available === true &&
finalCampOpenedLedger.battleId === finalBattleId &&
sameJsonValue(
finalCampOpenedLedger.cards.map((card) => card.id),
['gold', 'equipment', 'supplies', 'reputation', 'recruits', 'unlocks']
) &&
sameJsonValue(
finalCampOpenedLedger.actions.map((action) => action.id),
['equipment', 'supplies', 'sortie', 'close']
) &&
finalCampOpenedLedger.actions.filter((action) => action.primary).map((action) => action.id).join(',') === 'close' &&
finalCampOpenedLedger.cards.every((card) => boundsWithinFhdViewport(card.bounds)) &&
finalCampOpenedLedger.actions.every((action) => action.interactive && boundsWithinFhdViewport(action.bounds)) &&
sameJsonValue(finalCampRewardSaveBeforeClose, finalCampArrivalSave),
`Expected the requested final-camp ledger to open with six cards and four actions without changing settlement state: ${JSON.stringify(finalCampOpenedLedger)}`
);
const finalCampRewardClosePoint = await readCampVictoryRewardControlPoint(page, 'close');
await page.mouse.click(finalCampRewardClosePoint.x, finalCampRewardClosePoint.y);
await page.waitForFunction(
@@ -7968,23 +8257,24 @@ try {
);
const finalCampAfterRewardClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const finalCampRewardSaveAfterClose = await readCampaignSave(page);
const finalBattleId = 'sixty-sixth-battle-wuzhang-final';
assert(
sameJsonValue(
campaignSaveWithoutVictoryRewardNoticeDismissals(finalCampRewardSaveAfterClose),
campaignSaveWithoutVictoryRewardNoticeDismissals(finalCampRewardSaveBeforeClose)
) &&
sameJsonValue(finalCampRewardSaveAfterClose, finalCampRewardSaveBeforeClose) &&
sameJsonValue(
campaignVictoryAcknowledgementSnapshot(finalCampRewardSaveAfterClose, finalBattleId),
campaignVictoryAcknowledgementSnapshot(finalCampRewardSaveBeforeClose, finalBattleId)
) &&
campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId).current === false &&
campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId).slot1 === false &&
campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId).current === true &&
campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId).slot1 === true &&
campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveAfterClose, finalBattleId).current === true &&
campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveAfterClose, finalBattleId).slot1 === true &&
finalCampAfterRewardClose?.victorySettlementArrival?.battleId === finalBattleId &&
finalCampAfterRewardClose?.victorySettlementArrival?.blocking === false &&
finalCampAfterRewardClose?.victoryRewardAcknowledgement?.role === 'on-demand-ledger' &&
finalCampAfterRewardClose?.victoryRewardAcknowledgement?.available === true &&
finalCampAfterRewardClose?.victoryRewardAcknowledgement?.visible === false &&
finalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true &&
finalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true,
`Expected closing the final-camp reward notice to persist only its dismissal while preserving destination-specific NEW states: ${JSON.stringify({
`Expected closing an on-demand final-camp ledger to leave its already settled save and destination-specific NEW states completely unchanged: ${JSON.stringify({
beforeDismissal: campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId),
afterDismissal: campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveAfterClose, finalBattleId),
beforeAcknowledgement: campaignVictoryAcknowledgementSnapshot(finalCampRewardSaveBeforeClose, finalBattleId),
@@ -8003,10 +8293,18 @@ try {
const resumedFinalCampAfterRewardClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert(
resumedFinalCampAfterRewardClose?.campaign?.step === 'sixty-sixth-camp' &&
resumedFinalCampAfterRewardClose?.victorySettlementArrival === null &&
resumedFinalCampAfterRewardClose?.victoryRewardAcknowledgement?.role === 'on-demand-ledger' &&
resumedFinalCampAfterRewardClose?.victoryRewardAcknowledgement?.available === true &&
resumedFinalCampAfterRewardClose?.victoryRewardAcknowledgement?.visible === false &&
resumedFinalCampAfterRewardClose?.victoryRewardAcknowledgement?.battleId === finalBattleId &&
boundsWithinFhdViewport(
resumedFinalCampAfterRewardClose?.victoryRewardAcknowledgement?.toggleButtonBounds
) &&
resumedFinalCampAfterRewardClose?.victoryRewardAcknowledgement?.toggleInteractive === true &&
resumedFinalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true &&
resumedFinalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true,
`Expected title continue to keep the dismissed reward notice closed without consuming NEW states: ${JSON.stringify(resumedFinalCampAfterRewardClose)}`
`Expected title continue not to replay the settled arrival while keeping the on-demand ledger and destination NEW states available: ${JSON.stringify(resumedFinalCampAfterRewardClose)}`
);
await captureStableCampScreenshot(page, `${screenshotDir}/rc-final-camp.png`, 0, 'final camp');
await assertFinalCampRosterPagination(page);
@@ -10169,6 +10467,35 @@ async function waitForCamp(page) {
}));
}
async function waitForCampVictoryRewardLedgerReady(page, battleId) {
await page.waitForFunction((expectedBattleId) => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
const camp = window.__HEROS_DEBUG__?.camp?.();
const arrival = camp?.victorySettlementArrival;
const ledger = camp?.victoryRewardAcknowledgement;
const bounds = ledger?.toggleButtonBounds;
return (
activeScenes.includes('CampScene') &&
arrival?.battleId === expectedBattleId &&
arrival.blocking === false &&
ledger?.role === 'on-demand-ledger' &&
ledger.available === true &&
ledger.visible === false &&
ledger.battleId === expectedBattleId &&
ledger.toggleInteractive === true &&
Number.isFinite(bounds?.x) &&
Number.isFinite(bounds?.y) &&
Number.isFinite(bounds?.width) &&
Number.isFinite(bounds?.height) &&
bounds.width > 0 &&
bounds.height > 0
);
}, battleId, { timeout: 30000 });
await page.evaluate(() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
}
async function waitForCampSkinTransition(page, expectedSkinId) {
await page.waitForFunction((skinId) => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
@@ -10798,6 +11125,43 @@ async function readSortieOrderControlPoint(page, control, orderId) {
return probe;
}
async function readCampVictoryRewardLedgerTogglePoint(page) {
const probe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const canvas = document.querySelector('canvas');
const ledger = window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement;
const bounds = ledger?.toggleButtonBounds;
if (
!scene ||
!canvas ||
!bounds ||
ledger?.role !== 'on-demand-ledger' ||
ledger.available !== true ||
ledger.visible !== false ||
ledger.toggleInteractive !== true
) {
return { ready: false, ledger, bounds: bounds ?? null };
}
const canvasBounds = canvas.getBoundingClientRect();
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
ready: true,
bounds,
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
};
});
assert(
probe.ready === true &&
Number.isFinite(probe.x) &&
Number.isFinite(probe.y) &&
boundsWithinFhdViewport(probe.bounds),
`Expected an FHD-safe interactive camp reward-ledger toggle: ${JSON.stringify(probe)}`
);
return probe;
}
async function readCampVictoryRewardControlPoint(page, actionId) {
const probe = await page.evaluate((requestedActionId) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
@@ -12194,6 +12558,40 @@ function sameJsonValue(value, expected) {
return stableJson(value) === stableJson(expected);
}
function assertBattleResultDebriefText(resultTexts, rewards, context) {
const texts = Array.isArray(resultTexts) ? resultTexts : [];
const recruits = rewards?.recruits ?? [];
const unlocks = rewards?.unlocks ?? [];
const progressCount = recruits.length + unlocks.length;
const expectedProgressLine = progressCount > 0
? `신규 합류 ${recruits.length}명 · 새 전장 ${unlocks.length}`
: '새로운 합류·해금 없음';
const hiddenProgressNames = [
...recruits.map((recruit) => recruit.name),
...unlocks.map((unlock) => unlock.title)
].filter((label) => typeof label === 'string' && label.length > 0);
const progressSummaryVisible = progressCount > 0
? texts.some(
(text) =>
text === expectedProgressLine ||
text.startsWith(`신규 합류 ${recruits.length}명 · 새`)
)
: texts.includes(expectedProgressLine);
assert(
['소요 턴', '생존 아군', '격파 적군', '달성 목표'].every((label) => texts.includes(label)) &&
progressSummaryVisible &&
hiddenProgressNames.every(
(label) => !texts.some((text) => text.includes(label))
),
`Expected ${context} debrief to end its four metrics with achieved objectives and summarize recruits/unlocks only by count: ${JSON.stringify({
expectedProgressLine,
hiddenProgressNames,
texts
})}`
);
}
function campaignSaveWithoutCampRecruitmentEffects(save) {
const stableState = (state) => {
if (!state) {
@@ -12248,24 +12646,6 @@ function campaignVictoryNoticeDismissalSnapshot(save, battleId) {
};
}
function campaignSaveWithoutVictoryRewardNoticeDismissals(save) {
const stableState = (state) => {
if (!state) {
return state;
}
const {
updatedAt: _updatedAt,
dismissedVictoryRewardNoticeBattleIds: _dismissedVictoryRewardNoticeBattleIds,
...stableFields
} = state;
return stableFields;
};
return {
current: stableState(save?.current),
slot1: stableState(save?.slot1)
};
}
function campaignSaveWithoutVictoryRewardAcknowledgementEffects(save) {
const stableState = (state) => {
if (!state) {