fix: resume campaign progress safely

This commit is contained in:
2026-07-29 01:13:57 +09:00
parent 90de2a3559
commit 6f2b6e42a4
31 changed files with 9354 additions and 148 deletions

View File

@@ -206,18 +206,43 @@ try {
await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true });
await assertCanvasPainted(page, 'new game story');
await assertFreshStoryAssetStreaming(page, requestedResourceUrls, freshStoryRequestStartIndex);
const interruptedPrologue = await page.evaluate(
() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()
);
const interruptedPrologueSave = await readCampaignSave(page);
assert(
Number.isInteger(interruptedPrologue?.pageIndex) &&
interruptedPrologue.pageIndex > 0 &&
Boolean(interruptedPrologue.currentPageId),
`Expected the prologue interruption fixture to be beyond its first page: ${JSON.stringify(interruptedPrologue)}`
);
assertStoryCheckpointMatchesPage(
interruptedPrologue,
interruptedPrologueSave,
'interrupted prologue story'
);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await clickLegacyUi(page, 962, 310);
await waitForStoryReady(page);
const resumedPrologue = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.());
const resumedPrologueSave = await readCampaignSave(page);
assert(
resumedPrologue?.pageIndex === 0 &&
resumedPrologue?.pageIndex === interruptedPrologue.pageIndex &&
resumedPrologue?.currentPageId === interruptedPrologue.currentPageId &&
resumedPrologue?.presentation?.battleId === 'first-battle-zhuo-commandery' &&
resumedPrologue.presentation.arcId === 'yellow-turban' &&
resumedPrologue.presentation.stage === 'story' &&
resumedPrologue.presentation.washVisible === true,
`Expected a resumed prologue to retain the founding campaign presentation: ${JSON.stringify(resumedPrologue)}`
`Expected a resumed prologue to retain the exact interrupted page and founding campaign presentation: ${JSON.stringify({
interrupted: interruptedPrologue,
resumed: resumedPrologue
})}`
);
assertStoryCheckpointMatchesPage(
resumedPrologue,
resumedPrologueSave,
'resumed prologue story'
);
const firstBattleTransition = await advanceUntilBattle(page, 'first-battle-zhuo-commandery', {
startDeployment: false,
@@ -2651,6 +2676,20 @@ try {
undefined,
{ timeout: 30000 }
);
const interruptedFirstAftermath = await page.evaluate(
() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()
);
const interruptedFirstAftermathSave = await readCampaignSave(page);
assert(
interruptedFirstAftermath?.pageIndex === 1 &&
Boolean(interruptedFirstAftermath.currentPageId),
`Expected the first aftermath interruption fixture on its second page: ${JSON.stringify(interruptedFirstAftermath)}`
);
assertStoryCheckpointMatchesPage(
interruptedFirstAftermath,
interruptedFirstAftermathSave,
'interrupted first aftermath'
);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await clickLegacyUi(page, 962, 310);
@@ -2658,13 +2697,23 @@ try {
const resumedFirstAftermath = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.());
const resumedFirstAftermathSave = await readCampaignSave(page);
assert(
resumedFirstAftermath?.pageIndex === 0 &&
resumedFirstAftermath?.pageIndex === interruptedFirstAftermath.pageIndex &&
resumedFirstAftermath?.currentPageId === interruptedFirstAftermath.currentPageId &&
resumedFirstAftermath?.totalPages === 5 &&
resumedFirstAftermath?.presentation?.battleId === 'first-battle-zhuo-commandery' &&
resumedFirstAftermath.presentation.stage === 'aftermath' &&
resumedFirstAftermathSave.current?.pendingAftermathBattleId === 'first-battle-zhuo-commandery' &&
resumedFirstAftermathSave.slot1?.pendingAftermathBattleId === 'first-battle-zhuo-commandery',
`Expected continue after an interrupted aftermath to restore that aftermath before camp: ${JSON.stringify({ story: resumedFirstAftermath, save: resumedFirstAftermathSave })}`
`Expected continue after an interrupted aftermath to restore the exact interrupted page before camp: ${JSON.stringify({
interrupted: interruptedFirstAftermath,
resumed: resumedFirstAftermath,
save: resumedFirstAftermathSave
})}`
);
assertStoryCheckpointMatchesPage(
resumedFirstAftermath,
resumedFirstAftermathSave,
'resumed first aftermath'
);
assert(
sameJsonValue(
@@ -2704,19 +2753,23 @@ try {
'first-victory-zhang-fei',
'first-victory-next'
];
const firstAftermathRewardDisplays = [
directFirstAftermath.rewardDisplay,
...firstCampTransition.rewardDisplays
];
assert(
sameJsonValue(
firstCampTransition.rewardDisplays.map((display) => display.pageId),
firstAftermathRewardDisplays.map((display) => display.pageId),
firstVictoryStoryPageIds
) &&
firstCampTransition.rewardDisplays.every((display) => (
firstAftermathRewardDisplays.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)}`
`Expected all five first-victory aftermath pages to retain campaign context without duplicating exact rewards: ${JSON.stringify(firstAftermathRewardDisplays)}`
);
const firstNarrativeMemoriesByPage = Object.fromEntries(
firstCampTransition.narrativeMemories.map((memory) => [memory.pageId, memory])
@@ -6131,6 +6184,15 @@ try {
await waitForTitle(page);
}
const midCampaignSelectedUnitIds = [
'zhuge-liang',
'jiang-wei',
'wang-ping',
'zhao-yun',
'huang-quan',
'ma-liang',
'ma-dai'
];
await seedCampaignSave(page, {
battleId: 'fifty-eighth-battle-qishan-retreat',
battleTitle: '기산 후퇴로',
@@ -6138,12 +6200,36 @@ try {
gold: 7400,
turnNumber: 13,
defeatedEnemies: 16,
selectedSortieUnitIds: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'zhao-yun', 'huang-quan', 'ma-liang', 'ma-dai']
selectedSortieUnitIds: midCampaignSelectedUnitIds
});
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await clickLegacyUi(page, 962, 310);
await waitForCamp(page);
const midCampaignSelectionFixture = await page.evaluate((requestedUnitIds) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (
!scene ||
typeof scene.normalizedSortieUnitIds !== 'function' ||
typeof scene.persistSortieSelection !== 'function'
) {
return { ready: false, selectedUnitIds: [] };
}
scene.selectedSortieUnitIds = scene.normalizedSortieUnitIds(requestedUnitIds);
scene.persistSortieSelection();
return {
ready: true,
selectedUnitIds: [...scene.selectedSortieUnitIds]
};
}, midCampaignSelectedUnitIds);
assert(
midCampaignSelectionFixture.ready === true &&
sameJsonValue(
[...midCampaignSelectionFixture.selectedUnitIds].sort(),
[...midCampaignSelectedUnitIds].sort()
),
`Expected the mid-campaign fixture to select all seven officers after camp recruitment: ${JSON.stringify(midCampaignSelectionFixture)}`
);
await page.screenshot({ path: `${screenshotDir}/rc-mid-campaign-continue.png`, fullPage: true });
await assertCanvasPainted(page, 'mid campaign camp');
@@ -6597,8 +6683,12 @@ try {
JSON.stringify(swapPreview?.comparison?.projected?.selectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
swapPreview?.metrics?.length === 4 &&
swapPreview.metrics.some((metric) => metric.delta !== 0) &&
swapPreview?.comparison?.lostRoles?.includes('flank') &&
swapPreview?.comparison?.activeBondDelta === 0 &&
swapPreview?.comparison?.current?.coveredRoleCount === 3 &&
swapPreview?.comparison?.projected?.coveredRoleCount === 3 &&
swapPreview?.comparison?.current?.formationRoleCounts?.flank === 2 &&
swapPreview?.comparison?.projected?.formationRoleCounts?.flank === 1 &&
swapPreview?.comparison?.lostRoles?.length === 0 &&
swapPreview?.comparison?.activeBondDelta === -1 &&
swapPreview?.comparison?.gainedBonds?.length > 0 &&
swapPreview?.comparison?.lostBonds?.length > 0 &&
swapPreview?.strategyCoverage?.current?.count === 4 &&
@@ -6617,7 +6707,7 @@ try {
midFormationSwapPreviewProbe.panel.headline?.includes('IN 관우') &&
midFormationSwapPreviewProbe.panel.impact?.includes('전법 4/4→4/4') &&
midFormationSwapPreviewProbe.panel.impact?.includes('추격') &&
midFormationSwapPreviewProbe.panel.impact?.includes('공백 돌파') &&
midFormationSwapPreviewProbe.panel.impact?.includes('역할 유지') &&
midFormationSwapPreviewProbe.panel.detail?.includes('전법 유지') &&
midFormationSwapPreviewProbe.panel.detail?.includes('추격 해제') &&
midFormationSwapPreviewProbe.panel.detail?.includes('8%') &&
@@ -7753,6 +7843,61 @@ try {
campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave)
)
);
const midImprovementStoryCheckpointConsumed = ['current', 'slot1'].every(
(slot) => {
const before = updatedMobileResultSave[slot];
const after = pendingMidImprovementSave[slot];
const checkpoint = before?.storyCheckpoint;
return (
checkpoint?.phase === 'reading' &&
checkpoint.pageIndex === 0 &&
checkpoint.campaignStep === before?.step &&
checkpoint.revision ===
before?.storyCheckpointRevision &&
checkpoint.continuationIntent === 'default' &&
typeof checkpoint.sequenceId === 'string' &&
checkpoint.sequenceId.length > 0 &&
after?.storyCheckpoint === undefined &&
after?.storyCheckpointRevision ===
expectedNextStoryCheckpointRevision(
before?.storyCheckpointRevision
)
);
}
);
const expectedMidImprovementTargetStep =
typeof midImprovementTargetBattleId === 'string'
? midImprovementTargetBattleId.replace(
/-battle-.+$/,
'-battle'
)
: undefined;
const midImprovementPreparationCheckpointPersisted = [
'current',
'slot1'
].every((slot) => {
const before = updatedMobileResultSave[slot];
const after = pendingMidImprovementSave[slot];
const checkpoint =
after?.sortiePreparationCheckpoint;
return (
before?.sortiePreparationCheckpoint ===
undefined &&
checkpoint?.version === 1 &&
checkpoint.intent ===
'sortie-improvement' &&
checkpoint.mode === 'next-sortie' &&
checkpoint.sourceBattleId ===
midCampNextBattleId &&
checkpoint.targetBattleId ===
midImprovementTargetBattleId &&
checkpoint.targetCampaignStep ===
expectedMidImprovementTargetStep &&
Number.isFinite(
Date.parse(checkpoint.savedAt)
)
);
});
const midImprovementAftermathCompleted =
updatedMobileResultSave.current?.pendingAftermathBattleId === midCampNextBattleId &&
updatedMobileResultSave.slot1?.pendingAftermathBattleId === midCampNextBattleId &&
@@ -7802,19 +7947,63 @@ try {
boundsWithinFhdViewport(midImprovementLedger.toggleButtonBounds) &&
midImprovementLedger.toggleInteractive === true &&
midImprovementLedger.cards.length === 0 &&
midImprovementLedger.actions.length === 0 &&
midImprovementCampEntrySaveUnchanged &&
midImprovementAftermathCompleted &&
midImprovementRewardDestinationPending,
midImprovementLedger.actions.length === 0 &&
midImprovementCampEntrySaveUnchanged &&
midImprovementStoryCheckpointConsumed &&
midImprovementPreparationCheckpointPersisted &&
midImprovementAftermathCompleted &&
midImprovementRewardDestinationPending,
`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,
arrival: midImprovementArrival,
ledger: midImprovementLedger,
expectedBattleId: midCampNextBattleId,
saveUnchanged: midImprovementCampEntrySaveUnchanged,
aftermathCompleted: midImprovementAftermathCompleted,
rewardDestinationPending: midImprovementRewardDestinationPending,
expectedBattleId: midCampNextBattleId,
saveUnchanged: midImprovementCampEntrySaveUnchanged,
storyCheckpointConsumed:
midImprovementStoryCheckpointConsumed,
storyCheckpointBefore: {
current:
updatedMobileResultSave.current
?.storyCheckpoint,
slot1:
updatedMobileResultSave.slot1
?.storyCheckpoint
},
storyCheckpointAfter: {
current:
pendingMidImprovementSave.current
?.storyCheckpoint,
slot1:
pendingMidImprovementSave.slot1
?.storyCheckpoint
},
storyCheckpointRevisions: {
beforeCurrent:
updatedMobileResultSave.current
?.storyCheckpointRevision,
beforeSlot1:
updatedMobileResultSave.slot1
?.storyCheckpointRevision,
afterCurrent:
pendingMidImprovementSave.current
?.storyCheckpointRevision,
afterSlot1:
pendingMidImprovementSave.slot1
?.storyCheckpointRevision
},
preparationCheckpointPersisted:
midImprovementPreparationCheckpointPersisted,
preparationCheckpoint: {
current:
pendingMidImprovementSave.current
?.sortiePreparationCheckpoint,
slot1:
pendingMidImprovementSave.slot1
?.sortiePreparationCheckpoint
},
aftermathCompleted: midImprovementAftermathCompleted,
rewardDestinationPending: midImprovementRewardDestinationPending,
rewardAcknowledgements: midImprovementRewardAcknowledgements,
noticeDismissals: midImprovementNoticeDismissals
})}`
@@ -11375,6 +11564,39 @@ async function readCampaignSave(page) {
});
}
function assertStoryCheckpointMatchesPage(story, saves, label) {
const runtimeCheckpoint =
story?.storyCheckpoint?.persisted;
const currentCheckpoint = saves?.current?.storyCheckpoint;
const slotCheckpoint = saves?.slot1?.storyCheckpoint;
assert(
story?.storyCheckpoint?.enabled === true &&
Boolean(story.currentPageId) &&
runtimeCheckpoint?.sequenceId ===
story.storyCheckpoint.sequenceId &&
runtimeCheckpoint?.beatId === story.currentPageId &&
runtimeCheckpoint?.pageIndex === story.pageIndex &&
runtimeCheckpoint?.phase === 'reading' &&
currentCheckpoint?.sequenceId ===
runtimeCheckpoint.sequenceId &&
currentCheckpoint?.beatId === story.currentPageId &&
currentCheckpoint?.pageIndex === story.pageIndex &&
currentCheckpoint?.phase === 'reading' &&
slotCheckpoint?.sequenceId ===
runtimeCheckpoint.sequenceId &&
slotCheckpoint?.beatId === story.currentPageId &&
slotCheckpoint?.pageIndex === story.pageIndex &&
slotCheckpoint?.phase === 'reading' &&
sameJsonValue(currentCheckpoint, slotCheckpoint),
`Expected ${label} to persist its exact page checkpoint in runtime, current, and slot saves: ${JSON.stringify({
story,
runtimeCheckpoint,
currentCheckpoint,
slotCheckpoint
})}`
);
}
async function readBattleResultEvaluationControlPoint(page, control, presetId) {
const probe = await page.evaluate(({ control, presetId }) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -12558,6 +12780,16 @@ function sameJsonValue(value, expected) {
return stableJson(value) === stableJson(expected);
}
function expectedNextStoryCheckpointRevision(revision) {
const normalized =
Number.isSafeInteger(revision) && revision >= 0
? revision
: 0;
return normalized >= Number.MAX_SAFE_INTEGER
? 0
: normalized + 1;
}
function assertBattleResultDebriefText(resultTexts, rewards, context) {
const texts = Array.isArray(resultTexts) ? resultTexts : [];
const recruits = rewards?.recruits ?? [];
@@ -12604,6 +12836,10 @@ function campaignSaveWithoutCampRecruitmentEffects(save) {
acknowledgedVictoryRewardBattleIds: _acknowledgedVictoryRewardBattleIds,
acknowledgedVictoryRewardCategories: _acknowledgedVictoryRewardCategories,
pendingAftermathBattleId: _pendingAftermathBattleId,
storyCheckpointRevision: _storyCheckpointRevision,
storyCheckpoint: _storyCheckpoint,
sortiePreparationCheckpoint:
_sortiePreparationCheckpoint,
firstBattleReport,
...stableFields
} = state;