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

View File

@@ -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,