feat: confirm and undo sortie swaps
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -346,7 +346,7 @@ type SortieComparisonMetric = {
|
||||
};
|
||||
|
||||
type SortieFormationComparisonPreview = {
|
||||
source: 'focus' | 'hover-add' | 'hover-swap' | 'hover-blocked';
|
||||
source: 'focus' | 'hover-add' | 'hover-swap' | 'pinned-swap' | 'hover-blocked';
|
||||
mode: SortieUnitSynergyPreview['mode'] | 'swap';
|
||||
unitId: string;
|
||||
incomingUnitId: string | null;
|
||||
@@ -375,6 +375,42 @@ type SortieComparisonPanelView = {
|
||||
}[];
|
||||
impact: Phaser.GameObjects.Text;
|
||||
detail: Phaser.GameObjects.Text;
|
||||
actionButton: Phaser.GameObjects.Rectangle;
|
||||
actionLabel: Phaser.GameObjects.Text;
|
||||
};
|
||||
|
||||
type SortieConfigurationSnapshot = {
|
||||
selectedUnitIds: string[];
|
||||
formationAssignments: SortieFormationAssignments;
|
||||
itemAssignments: CampaignSortieItemAssignments;
|
||||
focusedUnitId: string;
|
||||
planFeedback: string;
|
||||
rosterScroll: number;
|
||||
};
|
||||
|
||||
type SortieSwapProjection = {
|
||||
outgoingUnit: UnitData;
|
||||
incomingUnit: UnitData;
|
||||
selectedUnitIds: string[];
|
||||
formationAssignments: SortieFormationAssignments;
|
||||
itemAssignments: CampaignSortieItemAssignments;
|
||||
incomingRole: SortieFormationRole;
|
||||
};
|
||||
|
||||
type SortieSwapUndoState = {
|
||||
outgoingUnitId: string;
|
||||
outgoingUnitName: string;
|
||||
incomingUnitId: string;
|
||||
incomingUnitName: string;
|
||||
before: SortieConfigurationSnapshot;
|
||||
applied: SortieConfigurationSnapshot;
|
||||
};
|
||||
|
||||
type SortieComparisonAction = {
|
||||
kind: 'confirm-swap' | 'undo-swap';
|
||||
label: string;
|
||||
outgoingUnitId: string;
|
||||
incomingUnitId: string;
|
||||
};
|
||||
|
||||
type CampaignTimelineChapter = {
|
||||
@@ -10824,6 +10860,8 @@ export class CampScene extends Phaser.Scene {
|
||||
private sortieItemAssignments: CampaignSortieItemAssignments = {};
|
||||
private sortieFocusedUnitId = 'liu-bei';
|
||||
private sortieHoveredUnitId?: string;
|
||||
private sortiePinnedSwapCandidateUnitId?: string;
|
||||
private sortieSwapUndoState?: SortieSwapUndoState;
|
||||
private sortieComparisonPanelView?: SortieComparisonPanelView;
|
||||
private sortieRosterScroll = 0;
|
||||
private sortiePlanFeedback = '';
|
||||
@@ -10846,6 +10884,8 @@ export class CampScene extends Phaser.Scene {
|
||||
this.dialogueObjects = [];
|
||||
this.sortieObjects = [];
|
||||
this.sortieComparisonPanelView = undefined;
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
this.sortieSwapUndoState = undefined;
|
||||
this.saveSlotObjects = [];
|
||||
this.saveSlotConfirmObjects = [];
|
||||
this.pendingSaveSlot = undefined;
|
||||
@@ -12194,6 +12234,8 @@ export class CampScene extends Phaser.Scene {
|
||||
private saveCampToSlot(slot: number) {
|
||||
soundDirector.playSelect();
|
||||
const returnToSortiePrep = this.sortieObjects.length > 0;
|
||||
this.sortieSwapUndoState = undefined;
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
this.hideCampSaveSlotPanel();
|
||||
this.campaign = saveCampaignState(getCampaignState(), slot);
|
||||
if (returnToSortiePrep) {
|
||||
@@ -12373,7 +12415,7 @@ export class CampScene extends Phaser.Scene {
|
||||
private showSortiePrep() {
|
||||
const wasVisible = this.sortieObjects.length > 0;
|
||||
this.hideCampSaveSlotPanel();
|
||||
this.hideSortiePrep();
|
||||
this.hideSortiePrep(false);
|
||||
this.campaign = getCampaignState();
|
||||
this.report = this.campaign.firstBattleReport ?? this.report;
|
||||
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds);
|
||||
@@ -12520,6 +12562,8 @@ export class CampScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
this.sortiePrepStep = step;
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
this.sortieHoveredUnitId = undefined;
|
||||
this.sortieRosterScroll = 0;
|
||||
soundDirector.playSelect();
|
||||
this.showSortiePrep();
|
||||
@@ -12765,15 +12809,26 @@ export class CampScene extends Phaser.Scene {
|
||||
const cardY = cardTop + rowIndex * (cardHeight + rowGap);
|
||||
const selected = this.isSortieSelected(unit.id);
|
||||
const focused = this.sortieFocusedUnitId === unit.id;
|
||||
const pinnedSwapCandidate = this.sortiePinnedSwapCandidateUnitId === unit.id;
|
||||
const required = this.isRequiredSortieUnit(unit.id);
|
||||
const recommendation = this.sortieRecommendation(unit.id);
|
||||
const availability = this.sortieUnitAvailability(unit);
|
||||
const role = this.sortieFormationRole(unit);
|
||||
const preview = this.sortieUnitSynergyPreview(unit);
|
||||
const fill = !availability.available ? 0x11151a : selected ? 0x172a22 : recommendation ? 0x241f17 : 0x151b24;
|
||||
const accentColor = focused ? palette.gold : selected ? palette.green : recommendation ? palette.gold : 0x53606c;
|
||||
const restingStrokeWidth = focused ? 2 : selected ? 1.5 : 1;
|
||||
const restingStrokeAlpha = focused ? 0.96 : selected ? 0.68 : recommendation ? 0.52 : 0.34;
|
||||
const canPinSwap = availability.available && !selected && selectedCount >= maxUnits &&
|
||||
this.isSortieSelected(this.sortieFocusedUnitId) && !this.isRequiredSortieUnit(this.sortieFocusedUnitId);
|
||||
const fill = !availability.available
|
||||
? 0x11151a
|
||||
: pinnedSwapCandidate
|
||||
? 0x1d2731
|
||||
: selected
|
||||
? 0x172a22
|
||||
: recommendation
|
||||
? 0x241f17
|
||||
: 0x151b24;
|
||||
const accentColor = focused || pinnedSwapCandidate ? palette.gold : selected ? palette.green : recommendation ? palette.gold : 0x53606c;
|
||||
const restingStrokeWidth = focused || pinnedSwapCandidate ? 2 : selected ? 1.5 : 1;
|
||||
const restingStrokeAlpha = focused || pinnedSwapCandidate ? 0.96 : selected ? 0.68 : recommendation ? 0.52 : 0.34;
|
||||
const card = this.trackSortie(this.add.rectangle(cardX, cardY, cardWidth, cardHeight, fill, !availability.available ? 0.62 : 0.96));
|
||||
card.setOrigin(0);
|
||||
card.setDepth(depth + 1);
|
||||
@@ -12781,6 +12836,16 @@ export class CampScene extends Phaser.Scene {
|
||||
card.setInteractive({ useHandCursor: true });
|
||||
card.on('wheel', handleRosterWheel);
|
||||
card.on('pointerdown', () => {
|
||||
const comparisonPreview = this.sortieFormationComparisonPreview();
|
||||
if (
|
||||
comparisonPreview?.mode === 'swap' &&
|
||||
comparisonPreview.incomingUnitId === unit.id &&
|
||||
(comparisonPreview.source === 'hover-swap' || comparisonPreview.source === 'pinned-swap')
|
||||
) {
|
||||
this.pinSortieSwapCandidate(unit.id);
|
||||
return;
|
||||
}
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
this.sortieFocusedUnitId = unit.id;
|
||||
soundDirector.playSelect();
|
||||
if (!availability.available) {
|
||||
@@ -12789,7 +12854,7 @@ export class CampScene extends Phaser.Scene {
|
||||
this.showSortiePrep();
|
||||
});
|
||||
|
||||
const accent = this.trackSortie(this.add.rectangle(cardX + 3, cardY + 3, 4, cardHeight - 6, accentColor, focused || selected ? 0.94 : recommendation ? 0.72 : 0.34));
|
||||
const accent = this.trackSortie(this.add.rectangle(cardX + 3, cardY + 3, 4, cardHeight - 6, accentColor, focused || selected || pinnedSwapCandidate ? 0.94 : recommendation ? 0.72 : 0.34));
|
||||
accent.setOrigin(0);
|
||||
accent.setDepth(depth + 2);
|
||||
const portraitView = this.renderFirstSortiePortrait(
|
||||
@@ -12802,6 +12867,9 @@ export class CampScene extends Phaser.Scene {
|
||||
false,
|
||||
accentColor
|
||||
);
|
||||
if (pinnedSwapCandidate && portraitView.image) {
|
||||
portraitView.image.setDisplaySize(60, 60);
|
||||
}
|
||||
card.on('pointerover', () => {
|
||||
this.sortieHoveredUnitId = unit.id;
|
||||
this.refreshCampaignSortieComparisonPanel();
|
||||
@@ -12818,14 +12886,22 @@ export class CampScene extends Phaser.Scene {
|
||||
this.sortieHoveredUnitId = undefined;
|
||||
}
|
||||
this.refreshCampaignSortieComparisonPanel();
|
||||
if (this.sortiePinnedSwapCandidateUnitId === unit.id) {
|
||||
card.setFillStyle(0x1d2731, 0.98);
|
||||
card.setStrokeStyle(2, palette.gold, 0.98);
|
||||
accent.setFillStyle(palette.gold, 0.98);
|
||||
portraitView.frame.setStrokeStyle(2, palette.gold, 0.98);
|
||||
portraitView.image?.setDisplaySize(60, 60);
|
||||
return;
|
||||
}
|
||||
card.setFillStyle(fill, !availability.available ? 0.62 : 0.96);
|
||||
card.setStrokeStyle(restingStrokeWidth, availability.available ? accentColor : 0x53606c, restingStrokeAlpha);
|
||||
accent.setFillStyle(accentColor, focused || selected ? 0.94 : recommendation ? 0.72 : 0.34);
|
||||
portraitView.frame.setStrokeStyle(2, accentColor, focused ? 0.92 : selected ? 0.78 : recommendation ? 0.62 : 0.42);
|
||||
portraitView.image?.setDisplaySize(56, 56);
|
||||
});
|
||||
const statusLabel = required ? '필수' : selected ? '출전' : recommendation ? '추천' : '대기';
|
||||
const statusColor = required || recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf';
|
||||
const statusLabel = required ? '필수' : selected ? '출전' : pinnedSwapCandidate ? '교체안' : recommendation ? '추천' : '대기';
|
||||
const statusColor = required || recommendation || pinnedSwapCandidate ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf';
|
||||
const status = this.trackSortie(this.add.text(cardX + cardWidth - 10, cardY + 8, statusLabel, this.textStyle(9, statusColor, true)));
|
||||
status.setOrigin(1, 0);
|
||||
status.setDepth(depth + 2);
|
||||
@@ -12849,14 +12925,18 @@ export class CampScene extends Phaser.Scene {
|
||||
|
||||
const canToggle = availability.available && !(selected && required);
|
||||
this.renderFirstSortieInlineButton(
|
||||
required ? '필수 출전' : selected ? '출전 해제' : '출전 등록',
|
||||
required ? '필수 출전' : selected ? '출전 해제' : pinnedSwapCandidate ? '후보 고정' : canPinSwap ? '교체 비교' : '출전 등록',
|
||||
cardX + cardWidth - 80,
|
||||
cardY + cardHeight - 25,
|
||||
70,
|
||||
20,
|
||||
canToggle,
|
||||
selected,
|
||||
selected || pinnedSwapCandidate,
|
||||
() => {
|
||||
if (canPinSwap || pinnedSwapCandidate) {
|
||||
this.pinSortieSwapCandidate(unit.id);
|
||||
return;
|
||||
}
|
||||
this.sortieFocusedUnitId = unit.id;
|
||||
this.toggleSortieUnit(unit.id);
|
||||
},
|
||||
@@ -12876,6 +12956,31 @@ export class CampScene extends Phaser.Scene {
|
||||
summary.setDepth(depth + 1);
|
||||
const headline = this.trackSortie(this.add.text(x + 18, y + 31, '', this.textStyle(11, '#c8d2dd', true)));
|
||||
headline.setDepth(depth + 1);
|
||||
const actionButton = this.trackSortie(this.add.rectangle(x + width - 108, y + 27, 90, 20, 0x263a2d, 0.98));
|
||||
actionButton.setOrigin(0);
|
||||
actionButton.setDepth(depth + 2);
|
||||
actionButton.setStrokeStyle(1, palette.green, 0.78);
|
||||
actionButton.setVisible(false);
|
||||
actionButton.disableInteractive();
|
||||
actionButton.on('pointerover', () => {
|
||||
if (actionButton.visible) {
|
||||
actionButton.setFillStyle(0x365044, 1).setStrokeStyle(1, palette.gold, 0.96);
|
||||
}
|
||||
});
|
||||
actionButton.on('pointerout', () => {
|
||||
const action = this.sortieComparisonAction();
|
||||
if (actionButton.visible && action) {
|
||||
const undo = action.kind === 'undo-swap';
|
||||
actionButton
|
||||
.setFillStyle(undo ? 0x243442 : 0x263a2d, 0.98)
|
||||
.setStrokeStyle(1, undo ? palette.blue : palette.green, 0.78);
|
||||
}
|
||||
});
|
||||
actionButton.on('pointerdown', () => this.handleSortieComparisonAction());
|
||||
const actionLabel = this.trackSortie(this.add.text(x + width - 63, y + 37, '', this.textStyle(10, '#f2e3bf', true)));
|
||||
actionLabel.setOrigin(0.5);
|
||||
actionLabel.setDepth(depth + 3);
|
||||
actionLabel.setVisible(false);
|
||||
|
||||
const metricGap = 6;
|
||||
const metricWidth = Math.floor((width - 36 - metricGap * 3) / 4);
|
||||
@@ -12905,7 +13010,9 @@ export class CampScene extends Phaser.Scene {
|
||||
headline,
|
||||
metrics,
|
||||
impact,
|
||||
detail
|
||||
detail,
|
||||
actionButton,
|
||||
actionLabel
|
||||
};
|
||||
this.refreshCampaignSortieComparisonPanel();
|
||||
}
|
||||
@@ -12917,11 +13024,14 @@ export class CampScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
const preview = this.sortieFormationComparisonPreview();
|
||||
const action = this.sortieComparisonAction(preview);
|
||||
this.refreshSortieComparisonActionButton(view, action);
|
||||
const isUndoAction = action?.kind === 'undo-swap';
|
||||
const current = preview?.current ?? this.sortieSynergySnapshot();
|
||||
const projected = preview?.projected ?? current;
|
||||
const showMetricTransition = Boolean(preview?.projected);
|
||||
const comparison = preview?.comparison;
|
||||
const isHoverPreview = preview?.source === 'hover-add' || preview?.source === 'hover-swap';
|
||||
const projected = isUndoAction ? current : preview?.projected ?? current;
|
||||
const showMetricTransition = !isUndoAction && Boolean(preview?.projected);
|
||||
const comparison = isUndoAction ? undefined : preview?.comparison;
|
||||
const isHoverPreview = preview?.source === 'hover-add' || preview?.source === 'hover-swap' || preview?.source === 'pinned-swap';
|
||||
const lostRole = Boolean(comparison?.lostRoles.length);
|
||||
const completeFormation = projected.coveredRoleCount === coreSortieSynergyRoles.length;
|
||||
view.background.setStrokeStyle(
|
||||
@@ -12943,7 +13053,11 @@ export class CampScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = preview.source === 'hover-swap'
|
||||
const title = isUndoAction && this.sortieSwapUndoState
|
||||
? `교체 완료 · ${this.sortieSwapUndoState.outgoingUnitName} → ${this.sortieSwapUndoState.incomingUnitName}`
|
||||
: preview.source === 'pinned-swap'
|
||||
? `교체안 고정 · ${preview.outgoingUnitName} → ${preview.incomingUnitName}`
|
||||
: preview.source === 'hover-swap'
|
||||
? `가상 교체 · ${preview.outgoingUnitName} → ${preview.incomingUnitName}`
|
||||
: preview.source === 'hover-add'
|
||||
? `가상 합류 · ${preview.incomingUnitName}`
|
||||
@@ -12955,7 +13069,7 @@ export class CampScene extends Phaser.Scene {
|
||||
38
|
||||
)
|
||||
);
|
||||
const headlineColor = preview.source === 'hover-swap' || preview.mode === 'add'
|
||||
const headlineColor = isUndoAction || preview.source === 'hover-swap' || preview.source === 'pinned-swap' || preview.mode === 'add'
|
||||
? '#a8ffd0'
|
||||
: preview.mode === 'remove'
|
||||
? '#ff9d7d'
|
||||
@@ -12964,7 +13078,10 @@ export class CampScene extends Phaser.Scene {
|
||||
: preview.mode === 'blocked'
|
||||
? '#87919c'
|
||||
: '#d4dce6';
|
||||
view.headline.setText(this.compactText(preview.headline, 64)).setColor(headlineColor);
|
||||
const headline = isUndoAction && this.sortieSwapUndoState
|
||||
? `${this.sortieSwapUndoState.incomingUnitName} 편성 완료 · 장비·보급 재점검`
|
||||
: preview.headline;
|
||||
view.headline.setText(this.compactText(headline, action ? 44 : 64)).setColor(headlineColor);
|
||||
|
||||
view.metrics.forEach((metricView, index) => {
|
||||
const metric = preview.metrics[index];
|
||||
@@ -12986,6 +13103,16 @@ export class CampScene extends Phaser.Scene {
|
||||
.setColor(color);
|
||||
});
|
||||
|
||||
if (isUndoAction && this.sortieSwapUndoState) {
|
||||
view.impact
|
||||
.setText(`현재 역할 ${current.coveredRoleCount}/3 · 공명 ${current.activeBondCount} · 지형 ${current.terrainGrade}`)
|
||||
.setColor('#a8ffd0');
|
||||
view.detail
|
||||
.setText('장비·보급은 자동 이전하지 않았습니다. 직전 교체는 한 번 되돌릴 수 있습니다.')
|
||||
.setColor('#ffdf7b');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!comparison) {
|
||||
view.impact
|
||||
.setText(this.compactText(preview.detail, 68))
|
||||
@@ -13032,6 +13159,65 @@ export class CampScene extends Phaser.Scene {
|
||||
.setColor(isHoverPreview ? '#ffdf7b' : '#9fb0bf');
|
||||
}
|
||||
|
||||
private refreshSortieComparisonActionButton(view: SortieComparisonPanelView, action?: SortieComparisonAction) {
|
||||
const visible = Boolean(action);
|
||||
view.actionButton.setVisible(visible);
|
||||
view.actionLabel.setVisible(visible);
|
||||
if (!action) {
|
||||
view.actionButton.disableInteractive();
|
||||
view.actionLabel.setText('');
|
||||
return;
|
||||
}
|
||||
|
||||
const undo = action.kind === 'undo-swap';
|
||||
view.actionButton
|
||||
.setInteractive({ useHandCursor: true })
|
||||
.setFillStyle(undo ? 0x243442 : 0x263a2d, 0.98)
|
||||
.setStrokeStyle(1, undo ? palette.blue : palette.green, 0.78);
|
||||
view.actionLabel.setText(action.label).setColor(undo ? '#cfe8ff' : '#e6ffd5');
|
||||
}
|
||||
|
||||
private sortieComparisonAction(
|
||||
preview = this.sortieFormationComparisonPreview()
|
||||
): SortieComparisonAction | undefined {
|
||||
if (
|
||||
preview?.source === 'pinned-swap' &&
|
||||
preview.mode === 'swap' &&
|
||||
preview.outgoingUnitId &&
|
||||
preview.incomingUnitId &&
|
||||
this.sortieSwapProjection(preview.outgoingUnitId, preview.incomingUnitId)
|
||||
) {
|
||||
return {
|
||||
kind: 'confirm-swap',
|
||||
label: '교체 확정',
|
||||
outgoingUnitId: preview.outgoingUnitId,
|
||||
incomingUnitId: preview.incomingUnitId
|
||||
};
|
||||
}
|
||||
|
||||
const undo = this.sortieSwapUndoState;
|
||||
if (undo && this.sortiePersistedConfigurationMatches(undo.applied)) {
|
||||
return {
|
||||
kind: 'undo-swap',
|
||||
label: '되돌리기',
|
||||
outgoingUnitId: undo.outgoingUnitId,
|
||||
incomingUnitId: undo.incomingUnitId
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private handleSortieComparisonAction() {
|
||||
const action = this.sortieComparisonAction();
|
||||
if (action?.kind === 'confirm-swap') {
|
||||
this.confirmPinnedSortieSwap();
|
||||
return;
|
||||
}
|
||||
if (action?.kind === 'undo-swap') {
|
||||
this.undoLastSortieSwap();
|
||||
}
|
||||
}
|
||||
|
||||
private renderFirstSortieRoleDiagram(x: number, y: number, width: number, height: number, depth: number) {
|
||||
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94));
|
||||
bg.setOrigin(0);
|
||||
@@ -14142,44 +14328,49 @@ export class CampScene extends Phaser.Scene {
|
||||
const hovered = this.sortieHoveredUnitId
|
||||
? this.sortieRosterUnits().find((unit) => unit.id === this.sortieHoveredUnitId)
|
||||
: undefined;
|
||||
const pinned = this.sortiePinnedSwapCandidateUnitId
|
||||
? this.sortieRosterUnits().find((unit) => unit.id === this.sortiePinnedSwapCandidateUnitId)
|
||||
: undefined;
|
||||
const candidate = hovered ?? pinned;
|
||||
const pinnedCandidate = candidate?.id === pinned?.id;
|
||||
|
||||
if (hovered && hovered.id !== focused?.id && !selectedIds.has(hovered.id)) {
|
||||
const availability = this.sortieUnitAvailability(hovered, scenario);
|
||||
if (candidate && candidate.id !== focused?.id && !selectedIds.has(candidate.id)) {
|
||||
const availability = this.sortieUnitAvailability(candidate, scenario);
|
||||
if (!availability.available) {
|
||||
return {
|
||||
source: 'hover-blocked',
|
||||
mode: 'blocked',
|
||||
unitId: hovered.id,
|
||||
incomingUnitId: hovered.id,
|
||||
incomingUnitName: hovered.name,
|
||||
unitId: candidate.id,
|
||||
incomingUnitId: candidate.id,
|
||||
incomingUnitName: candidate.name,
|
||||
outgoingUnitId: null,
|
||||
outgoingUnitName: null,
|
||||
selectedUnitIds,
|
||||
projectedSelectedUnitIds: selectedUnitIds,
|
||||
headline: `${hovered.name} 편성 불가 · ${availability.reason}`,
|
||||
headline: `${candidate.name} 편성 불가 · ${availability.reason}`,
|
||||
detail: '현재 출전 명단은 유지됩니다.',
|
||||
metrics: this.sortieComparisonMetrics(hovered, hovered, scenario),
|
||||
metrics: this.sortieComparisonMetrics(candidate, candidate, scenario),
|
||||
current
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedUnitIds.length < this.sortieMaxUnits(scenario)) {
|
||||
const projectedSelectedUnitIds = [...selectedUnitIds, hovered.id];
|
||||
const projectedSelectedUnitIds = [...selectedUnitIds, candidate.id];
|
||||
const projected = this.sortieSynergySnapshot(projectedSelectedUnitIds, scenario);
|
||||
const comparison = compareSortieSynergy(current, projected);
|
||||
return {
|
||||
source: 'hover-add',
|
||||
mode: 'add',
|
||||
unitId: hovered.id,
|
||||
incomingUnitId: hovered.id,
|
||||
incomingUnitName: hovered.name,
|
||||
unitId: candidate.id,
|
||||
incomingUnitId: candidate.id,
|
||||
incomingUnitName: candidate.name,
|
||||
outgoingUnitId: null,
|
||||
outgoingUnitName: null,
|
||||
selectedUnitIds,
|
||||
projectedSelectedUnitIds,
|
||||
headline: `IN ${hovered.name} · ${this.sortieFormationRoleLabel(this.sortieFormationRole(hovered, scenario))} 합류`,
|
||||
headline: `IN ${candidate.name} · ${this.sortieFormationRoleLabel(this.sortieFormationRole(candidate, scenario))} 합류`,
|
||||
detail: `빈 출전 슬롯에 합류했을 때의 가상 결과입니다.`,
|
||||
metrics: this.sortieComparisonMetrics(undefined, hovered, scenario),
|
||||
metrics: this.sortieComparisonMetrics(undefined, candidate, scenario),
|
||||
current,
|
||||
projected,
|
||||
comparison
|
||||
@@ -14193,38 +14384,44 @@ export class CampScene extends Phaser.Scene {
|
||||
return {
|
||||
source: 'hover-blocked',
|
||||
mode: 'waiting',
|
||||
unitId: hovered.id,
|
||||
incomingUnitId: hovered.id,
|
||||
incomingUnitName: hovered.name,
|
||||
unitId: candidate.id,
|
||||
incomingUnitId: candidate.id,
|
||||
incomingUnitName: candidate.name,
|
||||
outgoingUnitId: null,
|
||||
outgoingUnitName: null,
|
||||
selectedUnitIds,
|
||||
projectedSelectedUnitIds: selectedUnitIds,
|
||||
headline: `교체 기준 필요 · 출전 중인 비필수 무장을 먼저 클릭하세요.`,
|
||||
detail: `${hovered.name} 후보는 아직 실제 편성에 반영되지 않았습니다.`,
|
||||
metrics: this.sortieComparisonMetrics(hovered, hovered, scenario),
|
||||
detail: `${candidate.name} 후보는 아직 실제 편성에 반영되지 않았습니다.`,
|
||||
metrics: this.sortieComparisonMetrics(candidate, candidate, scenario),
|
||||
current
|
||||
};
|
||||
}
|
||||
|
||||
const projectedSelectedUnitIds = selectedUnitIds.map((unitId) => unitId === outgoing.id ? hovered.id : unitId);
|
||||
const projection = this.sortieSwapProjection(outgoing.id, candidate.id, scenario);
|
||||
if (!projection) {
|
||||
return undefined;
|
||||
}
|
||||
const projectedSelectedUnitIds = projection.selectedUnitIds;
|
||||
const projected = this.sortieSynergySnapshot(projectedSelectedUnitIds, scenario);
|
||||
const comparison = compareSortieSynergy(current, projected);
|
||||
const outgoingRole = this.sortieFormationRoleLabel(this.sortieFormationRole(outgoing, scenario));
|
||||
const incomingRole = this.sortieFormationRoleLabel(this.sortieFormationRole(hovered, scenario));
|
||||
const incomingRole = this.sortieFormationRoleLabel(projection.incomingRole);
|
||||
return {
|
||||
source: 'hover-swap',
|
||||
source: pinnedCandidate ? 'pinned-swap' : 'hover-swap',
|
||||
mode: 'swap',
|
||||
unitId: hovered.id,
|
||||
incomingUnitId: hovered.id,
|
||||
incomingUnitName: hovered.name,
|
||||
unitId: candidate.id,
|
||||
incomingUnitId: candidate.id,
|
||||
incomingUnitName: candidate.name,
|
||||
outgoingUnitId: outgoing.id,
|
||||
outgoingUnitName: outgoing.name,
|
||||
selectedUnitIds,
|
||||
projectedSelectedUnitIds,
|
||||
headline: `OUT ${outgoing.name} ${outgoingRole} · IN ${hovered.name} ${incomingRole}`,
|
||||
detail: `${outgoing.name} 대신 ${hovered.name}을 투입했을 때의 가상 결과입니다.`,
|
||||
metrics: this.sortieComparisonMetrics(outgoing, hovered, scenario),
|
||||
headline: `OUT ${outgoing.name} ${outgoingRole} · IN ${candidate.name} ${incomingRole}`,
|
||||
detail: pinnedCandidate
|
||||
? `${outgoing.name} 대신 ${candidate.name}을 투입하는 교체안을 고정했습니다.`
|
||||
: `${outgoing.name} 대신 ${candidate.name}을 투입했을 때의 가상 결과입니다.`,
|
||||
metrics: this.sortieComparisonMetrics(outgoing, candidate, scenario),
|
||||
current,
|
||||
projected,
|
||||
comparison
|
||||
@@ -14261,6 +14458,192 @@ export class CampScene extends Phaser.Scene {
|
||||
};
|
||||
}
|
||||
|
||||
private sortieSwapProjection(
|
||||
outgoingUnitId: string,
|
||||
incomingUnitId: string,
|
||||
scenario = this.nextSortieScenario()
|
||||
): SortieSwapProjection | undefined {
|
||||
if (outgoingUnitId === incomingUnitId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const roster = this.sortieRosterUnits();
|
||||
const outgoingUnit = roster.find((unit) => unit.id === outgoingUnitId);
|
||||
const incomingUnit = roster.find((unit) => unit.id === incomingUnitId);
|
||||
const selectedIds = new Set(this.selectedSortieUnitIds);
|
||||
if (
|
||||
!outgoingUnit ||
|
||||
!incomingUnit ||
|
||||
!selectedIds.has(outgoingUnitId) ||
|
||||
selectedIds.has(incomingUnitId) ||
|
||||
this.isRequiredSortieUnit(outgoingUnitId, scenario) ||
|
||||
this.selectedSortieUnitIds.length !== this.sortieMaxUnits(scenario) ||
|
||||
!this.sortieUnitAvailability(incomingUnit, scenario).available
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const replacedIds = this.selectedSortieUnitIds.map((unitId) => unitId === outgoingUnitId ? incomingUnitId : unitId);
|
||||
const selectedUnitIds = this.normalizedSortieUnitIds(replacedIds);
|
||||
const requiredIds = this.requiredSortieUnitIdsFor(scenario);
|
||||
if (
|
||||
selectedUnitIds.length !== this.selectedSortieUnitIds.length ||
|
||||
new Set(selectedUnitIds).size !== selectedUnitIds.length ||
|
||||
selectedUnitIds.includes(outgoingUnitId) ||
|
||||
!selectedUnitIds.includes(incomingUnitId) ||
|
||||
[...requiredIds].some((unitId) => roster.some((unit) => unit.id === unitId) && !selectedUnitIds.includes(unitId))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const incomingRole = this.sortieFormationRole(incomingUnit, scenario);
|
||||
const formationAssignments = { ...this.sortieFormationAssignments };
|
||||
delete formationAssignments[outgoingUnitId];
|
||||
delete formationAssignments[incomingUnitId];
|
||||
formationAssignments[incomingUnitId] = incomingRole;
|
||||
const itemAssignments = this.cloneSortieItemAssignments(this.sortieItemAssignments);
|
||||
delete itemAssignments[outgoingUnitId];
|
||||
delete itemAssignments[incomingUnitId];
|
||||
return {
|
||||
outgoingUnit,
|
||||
incomingUnit,
|
||||
selectedUnitIds,
|
||||
formationAssignments,
|
||||
itemAssignments,
|
||||
incomingRole
|
||||
};
|
||||
}
|
||||
|
||||
private pinSortieSwapCandidate(incomingUnitId: string) {
|
||||
const projection = this.sortieSwapProjection(this.sortieFocusedUnitId, incomingUnitId);
|
||||
if (!projection) {
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
this.refreshCampaignSortieComparisonPanel();
|
||||
this.showCampNotice('교체 기준과 후보 상태를 다시 확인하십시오.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.sortiePinnedSwapCandidateUnitId = incomingUnitId;
|
||||
this.sortieHoveredUnitId = undefined;
|
||||
soundDirector.playSelect();
|
||||
this.showSortiePrep();
|
||||
}
|
||||
|
||||
private captureSortieConfiguration(): SortieConfigurationSnapshot {
|
||||
return {
|
||||
selectedUnitIds: [...this.selectedSortieUnitIds],
|
||||
formationAssignments: { ...this.sortieFormationAssignments },
|
||||
itemAssignments: this.cloneSortieItemAssignments(this.sortieItemAssignments),
|
||||
focusedUnitId: this.sortieFocusedUnitId,
|
||||
planFeedback: this.sortiePlanFeedback,
|
||||
rosterScroll: this.sortieRosterScroll
|
||||
};
|
||||
}
|
||||
|
||||
private sortiePersistedConfigurationKey(
|
||||
selectedUnitIds: readonly string[],
|
||||
formationAssignments: SortieFormationAssignments,
|
||||
itemAssignments: CampaignSortieItemAssignments
|
||||
) {
|
||||
return JSON.stringify({
|
||||
selectedUnitIds,
|
||||
formationAssignments: Object.entries(formationAssignments).sort(([left], [right]) => left.localeCompare(right)),
|
||||
itemAssignments: Object.entries(itemAssignments)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([unitId, stocks]) => [unitId, Object.entries(stocks).sort(([left], [right]) => left.localeCompare(right))])
|
||||
});
|
||||
}
|
||||
|
||||
private sortiePersistedConfigurationMatches(snapshot: SortieConfigurationSnapshot) {
|
||||
const expected = this.sortiePersistedConfigurationKey(
|
||||
snapshot.selectedUnitIds,
|
||||
snapshot.formationAssignments,
|
||||
snapshot.itemAssignments
|
||||
);
|
||||
const runtime = this.sortiePersistedConfigurationKey(
|
||||
this.selectedSortieUnitIds,
|
||||
this.sortieFormationAssignments,
|
||||
this.sortieItemAssignments
|
||||
);
|
||||
const campaign = this.campaign ?? getCampaignState();
|
||||
const saved = this.sortiePersistedConfigurationKey(
|
||||
campaign.selectedSortieUnitIds,
|
||||
campaign.sortieFormationAssignments,
|
||||
campaign.sortieItemAssignments
|
||||
);
|
||||
return runtime === expected && saved === expected;
|
||||
}
|
||||
|
||||
private confirmPinnedSortieSwap() {
|
||||
const preview = this.sortieFormationComparisonPreview();
|
||||
if (
|
||||
preview?.source !== 'pinned-swap' ||
|
||||
preview.mode !== 'swap' ||
|
||||
!preview.outgoingUnitId ||
|
||||
!preview.incomingUnitId
|
||||
) {
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
this.refreshCampaignSortieComparisonPanel();
|
||||
this.showCampNotice('고정된 교체안이 없습니다. 후보 초상 카드를 먼저 클릭하십시오.');
|
||||
return;
|
||||
}
|
||||
|
||||
const projection = this.sortieSwapProjection(preview.outgoingUnitId, preview.incomingUnitId);
|
||||
if (!projection || JSON.stringify(projection.selectedUnitIds) !== JSON.stringify(preview.projectedSelectedUnitIds)) {
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
this.refreshCampaignSortieComparisonPanel();
|
||||
this.showCampNotice('교체 조건이 달라졌습니다. 편성 상태를 다시 확인하십시오.');
|
||||
return;
|
||||
}
|
||||
|
||||
const before = this.captureSortieConfiguration();
|
||||
this.sortieSwapUndoState = undefined;
|
||||
this.selectedSortieUnitIds = [...projection.selectedUnitIds];
|
||||
this.sortieFormationAssignments = { ...projection.formationAssignments };
|
||||
this.sortieItemAssignments = this.cloneSortieItemAssignments(projection.itemAssignments);
|
||||
this.sortieFocusedUnitId = projection.incomingUnit.id;
|
||||
this.sortieHoveredUnitId = undefined;
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
this.sortieRosterScroll = 0;
|
||||
this.sortiePlanFeedback = `${projection.outgoingUnit.name} → ${projection.incomingUnit.name} 교체 완료 · 장비·보급 재점검`;
|
||||
this.persistSortieSelection();
|
||||
this.sortieSwapUndoState = {
|
||||
outgoingUnitId: projection.outgoingUnit.id,
|
||||
outgoingUnitName: projection.outgoingUnit.name,
|
||||
incomingUnitId: projection.incomingUnit.id,
|
||||
incomingUnitName: projection.incomingUnit.name,
|
||||
before,
|
||||
applied: this.captureSortieConfiguration()
|
||||
};
|
||||
soundDirector.playSelect();
|
||||
this.showSortiePrep();
|
||||
this.showCampNotice(`${projection.incomingUnit.name} 교체 완료 · 장비·보급은 자동 이전하지 않았습니다.`);
|
||||
}
|
||||
|
||||
private undoLastSortieSwap() {
|
||||
const undo = this.sortieSwapUndoState;
|
||||
if (!undo || !this.sortiePersistedConfigurationMatches(undo.applied)) {
|
||||
this.sortieSwapUndoState = undefined;
|
||||
this.refreshCampaignSortieComparisonPanel();
|
||||
this.showCampNotice('이후 편성이 변경되어 직전 교체를 되돌릴 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.sortieSwapUndoState = undefined;
|
||||
this.selectedSortieUnitIds = [...undo.before.selectedUnitIds];
|
||||
this.sortieFormationAssignments = { ...undo.before.formationAssignments };
|
||||
this.sortieItemAssignments = this.cloneSortieItemAssignments(undo.before.itemAssignments);
|
||||
this.sortieFocusedUnitId = undo.before.focusedUnitId;
|
||||
this.sortiePlanFeedback = undo.before.planFeedback;
|
||||
this.sortieRosterScroll = undo.before.rosterScroll;
|
||||
this.sortieHoveredUnitId = undefined;
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
this.persistSortieSelection();
|
||||
soundDirector.playSelect();
|
||||
this.showSortiePrep();
|
||||
this.showCampNotice(`${undo.outgoingUnitName} → ${undo.incomingUnitName} 교체를 되돌렸습니다.`);
|
||||
}
|
||||
|
||||
private sortieComparisonMetrics(
|
||||
currentUnit: UnitData | undefined,
|
||||
projectedUnit: UnitData | undefined,
|
||||
@@ -15393,10 +15776,13 @@ export class CampScene extends Phaser.Scene {
|
||||
return flow.campaignStep === 'ending-complete' && !flow.nextBattleId;
|
||||
}
|
||||
|
||||
private hideSortiePrep() {
|
||||
private hideSortiePrep(clearPinnedSwap = true) {
|
||||
this.sortieObjects.forEach((object) => object.destroy());
|
||||
this.sortieObjects = [];
|
||||
this.sortieHoveredUnitId = undefined;
|
||||
if (clearPinnedSwap) {
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
}
|
||||
this.sortieComparisonPanelView = undefined;
|
||||
}
|
||||
|
||||
@@ -16915,6 +17301,8 @@ export class CampScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private persistSortieSelection() {
|
||||
this.sortieSwapUndoState = undefined;
|
||||
this.sortiePinnedSwapCandidateUnitId = undefined;
|
||||
const campaign = this.campaign ?? getCampaignState();
|
||||
this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(this.sortieFormationAssignments);
|
||||
this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.sortieItemAssignments);
|
||||
@@ -17166,6 +17554,8 @@ export class CampScene extends Phaser.Scene {
|
||||
const portraitRosterVisibleCount = 8;
|
||||
const portraitRosterMaxScroll = Math.max(0, portraitRosterUnits.length - portraitRosterVisibleCount);
|
||||
const portraitRosterScroll = Phaser.Math.Clamp(this.sortieRosterScroll, 0, portraitRosterMaxScroll);
|
||||
const sortieComparisonPreview = this.sortieFormationComparisonPreview();
|
||||
const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview);
|
||||
return {
|
||||
scene: this.scene.key,
|
||||
activeTab: this.activeTab,
|
||||
@@ -17217,7 +17607,16 @@ export class CampScene extends Phaser.Scene {
|
||||
sortieDeploymentPreview: this.sortieDeploymentPreviewDebug(),
|
||||
sortieFocusedUnitId: this.sortieFocusedUnitId,
|
||||
sortieHoveredUnitId: this.sortieHoveredUnitId ?? null,
|
||||
sortieComparisonPreview: this.sortieFormationComparisonPreview() ?? null,
|
||||
sortiePinnedSwapCandidateUnitId: this.sortiePinnedSwapCandidateUnitId ?? null,
|
||||
sortieComparisonPreview: sortieComparisonPreview ?? null,
|
||||
sortieComparisonAction: sortieComparisonAction ? { ...sortieComparisonAction, enabled: true } : null,
|
||||
sortieSwapUndo: this.sortieSwapUndoState
|
||||
? {
|
||||
available: sortieComparisonAction?.kind === 'undo-swap',
|
||||
outgoingUnitId: this.sortieSwapUndoState.outgoingUnitId,
|
||||
incomingUnitId: this.sortieSwapUndoState.incomingUnitId
|
||||
}
|
||||
: null,
|
||||
sortieFocusedUnit: this.sortieFocusedUnitSummary(),
|
||||
sortiePlanFeedback: this.sortiePlanFeedback,
|
||||
sortieChecklist,
|
||||
|
||||
Reference in New Issue
Block a user