feat: confirm and undo sortie swaps

This commit is contained in:
2026-07-10 20:51:05 +09:00
parent fa57f4c1dd
commit 481e870321
2 changed files with 771 additions and 49 deletions

View File

@@ -387,6 +387,33 @@ try {
);
await advanceSortiePrepStep(page, 'formation');
const midFormationSwapFixture = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const state = window.__HEROS_DEBUG__?.camp();
const preservedUnitId = state?.selectedSortieUnitIds?.find((unitId) => unitId !== 'ma-dai');
if (!scene || !preservedUnitId || typeof scene.persistSortieSelection !== 'function' || typeof scene.showSortiePrep !== 'function') {
return { ready: false, preservedUnitId: preservedUnitId ?? null };
}
scene.sortieFormationAssignments = {
...scene.sortieFormationAssignments,
'ma-dai': 'flank'
};
scene.sortieItemAssignments = {
...scene.sortieItemAssignments,
'ma-dai': { ...(scene.sortieItemAssignments?.['ma-dai'] ?? {}), bean: 1 },
[preservedUnitId]: { ...(scene.sortieItemAssignments?.[preservedUnitId] ?? {}), salve: 1 }
};
scene.persistSortieSelection();
scene.showSortiePrep();
scene.persistSortieSelection();
scene.showSortiePrep();
return { ready: true, preservedUnitId };
});
assert(
midFormationSwapFixture.ready === true && midFormationSwapFixture.preservedUnitId,
`Expected a saved role and supply fixture for the swap regression: ${JSON.stringify(midFormationSwapFixture)}`
);
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation.png`, fullPage: true });
await assertCanvasPainted(page, 'mid camp sortie formation');
@@ -401,6 +428,19 @@ try {
const midFormationSelectedBefore = [...(midFormationPortraitState?.selectedSortieUnitIds ?? [])];
const midFormationCampaignSelectedBefore = [...(midFormationPortraitState?.campaign?.selectedSortieUnitIds ?? [])];
const midFormationAssignmentsBefore = { ...(midFormationPortraitState?.sortieFormationAssignments ?? {}) };
const midFormationItemAssignmentsBefore = structuredClone(midFormationPortraitState?.sortieItemAssignments ?? {});
const midFormationSaveBefore = await readCampaignSave(page);
assert(
midFormationAssignmentsBefore['ma-dai'] === 'flank' &&
midFormationItemAssignmentsBefore['ma-dai']?.bean === 1 &&
midFormationItemAssignmentsBefore[midFormationSwapFixture.preservedUnitId]?.salve === 1,
`Expected the swap fixture to retain Ma Dai and another officer's assignments: ${JSON.stringify({
formation: midFormationAssignmentsBefore,
items: midFormationItemAssignmentsBefore,
preservedUnitId: midFormationSwapFixture.preservedUnitId
})}`
);
await page.mouse.click(210, 447);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieFocusedUnitId === 'ma-dai', undefined, { timeout: 30000 });
const swapFocusState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
@@ -430,7 +470,17 @@ try {
});
const midFormationSwapPreviewState = midFormationSwapPreviewProbe.state;
const swapPreview = midFormationSwapPreviewState?.sortieComparisonPreview;
const expectedProjectedSwapIds = midFormationSelectedBefore.map((unitId) => unitId === 'ma-dai' ? 'guan-yu' : unitId);
const projectedSwapIdSet = new Set(
midFormationSelectedBefore.map((unitId) => unitId === 'ma-dai' ? 'guan-yu' : unitId)
);
const expectedProjectedSwapIds = [
...(midFormationSwapPreviewState?.sortieRoster ?? [])
.filter((unit) => unit.required && projectedSwapIdSet.has(unit.id))
.map((unit) => unit.id),
...(midFormationSwapPreviewState?.sortieRoster ?? [])
.filter((unit) => !unit.required && projectedSwapIdSet.has(unit.id))
.map((unit) => unit.id)
].slice(0, midFormationSelectedBefore.length);
assert(
midFormationPortraitState.sortiePortraitRoster.some(
(unit) => unit.id === swapPreview?.incomingUnitId && unit.portraitReady && unit.portraitTextureKey?.startsWith('portrait-card-')
@@ -504,6 +554,214 @@ try {
`Expected pointerout to restore the visible Ma Dai focus preview: ${JSON.stringify(afterSwapPreviewProbe)}`
);
await page.mouse.move(530, 544);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.camp();
return state?.sortieHoveredUnitId === 'guan-yu' && state?.sortieComparisonPreview?.source === 'hover-swap';
}, undefined, { timeout: 30000 });
await page.mouse.click(530, 544);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.camp();
return state?.sortiePinnedSwapCandidateUnitId === 'guan-yu' &&
state?.sortieComparisonPreview?.source === 'pinned-swap' &&
state?.sortieComparisonAction?.kind === 'confirm-swap';
}, undefined, { timeout: 30000 });
const pinnedSwapProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const state = window.__HEROS_DEBUG__?.camp();
const view = scene?.sortieComparisonPanelView;
return {
state,
action: {
buttonVisible: view?.actionButton?.visible ?? false,
labelVisible: view?.actionLabel?.visible ?? false,
label: view?.actionLabel?.text ?? null
}
};
});
const pinnedSwapState = pinnedSwapProbe.state;
const pinnedSwapPreview = pinnedSwapState?.sortieComparisonPreview;
const pinnedSwapSave = await readCampaignSave(page);
const previewIncomingRole = pinnedSwapState?.sortieRoster?.find((unit) => unit.id === 'guan-yu')?.formationRole;
assert(
pinnedSwapPreview?.source === 'pinned-swap' &&
pinnedSwapPreview?.mode === 'swap' &&
pinnedSwapPreview?.outgoingUnitId === 'ma-dai' &&
pinnedSwapPreview?.incomingUnitId === 'guan-yu' &&
JSON.stringify(pinnedSwapPreview?.projectedSelectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
previewIncomingRole === 'front' &&
pinnedSwapState?.sortieComparisonAction?.label === '교체 확정' &&
pinnedSwapState?.sortieComparisonAction?.outgoingUnitId === 'ma-dai' &&
pinnedSwapState?.sortieComparisonAction?.incomingUnitId === 'guan-yu' &&
pinnedSwapProbe.action.buttonVisible === true &&
pinnedSwapProbe.action.labelVisible === true &&
pinnedSwapProbe.action.label === '교체 확정',
`Expected clicking Guan Yu's card body to pin the canonical Ma Dai swap with a visible confirm action: ${JSON.stringify(pinnedSwapProbe)}`
);
assert(
JSON.stringify(pinnedSwapState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore) &&
JSON.stringify(pinnedSwapState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore) &&
sameJsonValue(pinnedSwapState?.sortieFormationAssignments, midFormationAssignmentsBefore) &&
sameJsonValue(pinnedSwapState?.sortieItemAssignments, midFormationItemAssignmentsBefore) &&
JSON.stringify(pinnedSwapSave.current?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.current?.selectedSortieUnitIds) &&
JSON.stringify(pinnedSwapSave.slot1?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.slot1?.selectedSortieUnitIds) &&
sameJsonValue(pinnedSwapSave.current?.sortieFormationAssignments, midFormationSaveBefore.current?.sortieFormationAssignments) &&
sameJsonValue(pinnedSwapSave.slot1?.sortieFormationAssignments, midFormationSaveBefore.slot1?.sortieFormationAssignments) &&
sameJsonValue(pinnedSwapSave.current?.sortieItemAssignments, midFormationSaveBefore.current?.sortieItemAssignments) &&
sameJsonValue(pinnedSwapSave.slot1?.sortieItemAssignments, midFormationSaveBefore.slot1?.sortieItemAssignments),
`Expected pinning the swap to preserve runtime, campaign, current-save, and slot-1 assignments: ${JSON.stringify({
state: pinnedSwapState,
save: pinnedSwapSave
})}`
);
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-swap-pinned.png`, fullPage: true });
const confirmSwapActionPoint = await readSortieComparisonActionPoint(page);
assert(
confirmSwapActionPoint.label === '교체 확정',
`Expected the scaled Phaser action button to expose the confirm label: ${JSON.stringify(confirmSwapActionPoint)}`
);
await page.mouse.click(confirmSwapActionPoint.x, confirmSwapActionPoint.y);
await page.waitForFunction((projectedIds) => {
const state = window.__HEROS_DEBUG__?.camp();
return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(projectedIds) &&
state?.sortieFormationAssignments?.['ma-dai'] === undefined &&
state?.sortieFormationAssignments?.['guan-yu'] === 'front' &&
state?.sortieItemAssignments?.['ma-dai'] === undefined &&
state?.sortieItemAssignments?.['guan-yu'] === undefined &&
state?.sortieSwapUndo?.available === true &&
state?.sortieComparisonAction?.kind === 'undo-swap';
}, expectedProjectedSwapIds, { timeout: 30000 });
const confirmedSwapState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const confirmedSwapSave = await readCampaignSave(page);
const expectedConfirmedAssignments = { ...midFormationAssignmentsBefore };
delete expectedConfirmedAssignments['ma-dai'];
expectedConfirmedAssignments['guan-yu'] = previewIncomingRole;
const expectedConfirmedItemAssignments = structuredClone(midFormationItemAssignmentsBefore);
delete expectedConfirmedItemAssignments['ma-dai'];
delete expectedConfirmedItemAssignments['guan-yu'];
assert(
JSON.stringify(confirmedSwapState?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
JSON.stringify(confirmedSwapState?.campaign?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
JSON.stringify(confirmedSwapSave.current?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
JSON.stringify(confirmedSwapSave.slot1?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds),
`Expected swap confirmation to persist the canonical projected roster everywhere: ${JSON.stringify({
expectedProjectedSwapIds,
runtime: confirmedSwapState?.selectedSortieUnitIds,
campaign: confirmedSwapState?.campaign?.selectedSortieUnitIds,
current: confirmedSwapSave.current?.selectedSortieUnitIds,
slot1: confirmedSwapSave.slot1?.selectedSortieUnitIds
})}`
);
assert(
sameJsonValue(confirmedSwapState?.sortieFormationAssignments, expectedConfirmedAssignments) &&
sameJsonValue(confirmedSwapState?.campaign?.sortieFormationAssignments, expectedConfirmedAssignments) &&
sameJsonValue(confirmedSwapSave.current?.sortieFormationAssignments, expectedConfirmedAssignments) &&
sameJsonValue(confirmedSwapSave.slot1?.sortieFormationAssignments, expectedConfirmedAssignments) &&
sameJsonValue(confirmedSwapState?.sortieItemAssignments, expectedConfirmedItemAssignments) &&
sameJsonValue(confirmedSwapState?.campaign?.sortieItemAssignments, expectedConfirmedItemAssignments) &&
sameJsonValue(confirmedSwapSave.current?.sortieItemAssignments, expectedConfirmedItemAssignments) &&
sameJsonValue(confirmedSwapSave.slot1?.sortieItemAssignments, expectedConfirmedItemAssignments),
`Expected confirmation to remove Ma Dai's role and supply, assign Guan Yu's preview role without transferring supply, and preserve every other assignment: ${JSON.stringify({
expectedFormation: expectedConfirmedAssignments,
expectedItems: expectedConfirmedItemAssignments,
state: confirmedSwapState,
save: confirmedSwapSave
})}`
);
assert(
confirmedSwapState?.sortieFocusedUnitId === 'guan-yu' &&
confirmedSwapState?.sortiePinnedSwapCandidateUnitId === null &&
confirmedSwapState?.sortieSwapUndo?.available === true &&
confirmedSwapState?.sortieSwapUndo?.outgoingUnitId === 'ma-dai' &&
confirmedSwapState?.sortieSwapUndo?.incomingUnitId === 'guan-yu' &&
confirmedSwapState?.sortieComparisonAction?.kind === 'undo-swap' &&
confirmedSwapState?.sortieComparisonAction?.label === '되돌리기' &&
confirmedSwapState?.sortiePlanFeedback?.includes('장비·보급'),
`Expected confirmed swap feedback and one-use undo action: ${JSON.stringify(confirmedSwapState)}`
);
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-swap-confirmed.png`, fullPage: true });
const undoSwapActionPoint = await readSortieComparisonActionPoint(page);
assert(
undoSwapActionPoint.label === '되돌리기',
`Expected the scaled Phaser action button to switch to undo: ${JSON.stringify(undoSwapActionPoint)}`
);
await page.mouse.click(undoSwapActionPoint.x, undoSwapActionPoint.y);
await page.waitForFunction((selectedBefore) => {
const state = window.__HEROS_DEBUG__?.camp();
return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(selectedBefore) &&
state?.sortieFocusedUnitId === 'ma-dai' &&
state?.sortieComparisonAction === null &&
!state?.sortieSwapUndo?.available;
}, midFormationSelectedBefore, { timeout: 30000 });
const undoneSwapProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const state = window.__HEROS_DEBUG__?.camp();
const view = scene?.sortieComparisonPanelView;
return {
state,
action: {
buttonVisible: view?.actionButton?.visible ?? false,
labelVisible: view?.actionLabel?.visible ?? false,
label: view?.actionLabel?.text ?? null
}
};
});
const undoneSwapState = undoneSwapProbe.state;
const undoneSwapSave = await readCampaignSave(page);
const undoRestoreChecks = {
runtimeSelection: JSON.stringify(undoneSwapState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore),
campaignSelection: JSON.stringify(undoneSwapState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore),
currentSaveSelection: JSON.stringify(undoneSwapSave.current?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.current?.selectedSortieUnitIds),
slotSelection: JSON.stringify(undoneSwapSave.slot1?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.slot1?.selectedSortieUnitIds),
runtimeFormation: sameJsonValue(undoneSwapState?.sortieFormationAssignments, midFormationAssignmentsBefore),
campaignFormation: sameJsonValue(undoneSwapState?.campaign?.sortieFormationAssignments, midFormationAssignmentsBefore),
currentSaveFormation: sameJsonValue(undoneSwapSave.current?.sortieFormationAssignments, midFormationSaveBefore.current?.sortieFormationAssignments),
slotFormation: sameJsonValue(undoneSwapSave.slot1?.sortieFormationAssignments, midFormationSaveBefore.slot1?.sortieFormationAssignments),
runtimeItems: sameJsonValue(undoneSwapState?.sortieItemAssignments, midFormationItemAssignmentsBefore),
campaignItems: sameJsonValue(undoneSwapState?.campaign?.sortieItemAssignments, midFormationItemAssignmentsBefore),
currentSaveItems: sameJsonValue(undoneSwapSave.current?.sortieItemAssignments, midFormationSaveBefore.current?.sortieItemAssignments),
slotItems: sameJsonValue(undoneSwapSave.slot1?.sortieItemAssignments, midFormationSaveBefore.slot1?.sortieItemAssignments)
};
assert(
Object.values(undoRestoreChecks).every(Boolean),
`Expected one-time undo to restore the exact roster, role map, and supply map in runtime and both saves: ${JSON.stringify({
checks: undoRestoreChecks,
expected: {
runtimeSelected: midFormationSelectedBefore,
campaignSelected: midFormationCampaignSelectedBefore,
currentSaveSelected: midFormationSaveBefore.current?.selectedSortieUnitIds,
slotSelected: midFormationSaveBefore.slot1?.selectedSortieUnitIds,
formation: midFormationAssignmentsBefore,
items: midFormationItemAssignmentsBefore
},
actual: {
runtimeSelected: undoneSwapState?.selectedSortieUnitIds,
campaignSelected: undoneSwapState?.campaign?.selectedSortieUnitIds,
currentSaveSelected: undoneSwapSave.current?.selectedSortieUnitIds,
slotSelected: undoneSwapSave.slot1?.selectedSortieUnitIds,
runtimeFormation: undoneSwapState?.sortieFormationAssignments,
campaignFormation: undoneSwapState?.campaign?.sortieFormationAssignments,
currentSaveFormation: undoneSwapSave.current?.sortieFormationAssignments,
slotFormation: undoneSwapSave.slot1?.sortieFormationAssignments,
runtimeItems: undoneSwapState?.sortieItemAssignments,
campaignItems: undoneSwapState?.campaign?.sortieItemAssignments,
currentSaveItems: undoneSwapSave.current?.sortieItemAssignments,
slotItems: undoneSwapSave.slot1?.sortieItemAssignments
}
})}`
);
assert(
undoneSwapState?.sortieFocusedUnitId === 'ma-dai' &&
undoneSwapState?.sortiePinnedSwapCandidateUnitId === null &&
undoneSwapState?.sortieComparisonAction === null &&
!undoneSwapState?.sortieSwapUndo?.available &&
undoneSwapProbe.action.buttonVisible === false &&
undoneSwapProbe.action.labelVisible === false,
`Expected undo to restore Ma Dai focus and consume the panel action exactly once: ${JSON.stringify(undoneSwapProbe)}`
);
const midFormationFirstPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
await page.mouse.click(658, 196);
await page.waitForFunction((previousScroll) => {
@@ -979,6 +1237,49 @@ async function readCampaignSave(page) {
});
}
async function readSortieComparisonActionPoint(page) {
const probe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const actionButton = scene?.sortieComparisonPanelView?.actionButton;
const actionLabel = scene?.sortieComparisonPanelView?.actionLabel;
const canvas = document.querySelector('canvas');
if (!scene || !actionButton?.visible || !actionLabel?.visible || !canvas) {
return {
ready: false,
buttonVisible: actionButton?.visible ?? false,
labelVisible: actionLabel?.visible ?? false,
label: actionLabel?.text ?? null
};
}
const bounds = actionButton.getBounds();
const canvasBounds = canvas.getBoundingClientRect();
const logicalWidth = scene.scale.width;
const logicalHeight = scene.scale.height;
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
ready: true,
label: actionLabel.text,
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
canvasBounds: {
x: canvasBounds.x,
y: canvasBounds.y,
width: canvasBounds.width,
height: canvasBounds.height
},
logicalSize: { width: logicalWidth, height: logicalHeight },
x: canvasBounds.left + centerX * canvasBounds.width / logicalWidth,
y: canvasBounds.top + centerY * canvasBounds.height / logicalHeight
};
});
assert(
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
`Expected a visible Phaser comparison action with a canvas-scaled click point: ${JSON.stringify(probe)}`
);
return probe;
}
async function readBattleDoctrineProbe(page) {
return page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.battle();
@@ -1146,6 +1447,28 @@ function assertSameMembers(values, expected, message) {
assert(expected.every((value) => valueSet.has(value)), message);
}
function sameJsonValue(value, expected) {
return stableJson(value) === stableJson(expected);
}
function stableJson(value) {
return JSON.stringify(normalize(value));
function normalize(entry) {
if (Array.isArray(entry)) {
return entry.map(normalize);
}
if (entry && typeof entry === 'object') {
return Object.fromEntries(
Object.keys(entry)
.sort()
.map((key) => [key, normalize(entry[key])])
);
}
return entry;
}
}
function assertDeploymentMatchesPreview(positions, preview, message) {
assert(Array.isArray(positions) && Array.isArray(preview), message);
assert(positions.length === preview.length, message);