diff --git a/scripts/verify-third-camp-preparation-browser.mjs b/scripts/verify-third-camp-preparation-browser.mjs index 38a79d4..7951e9c 100644 --- a/scripts/verify-third-camp-preparation-browser.mjs +++ b/scripts/verify-third-camp-preparation-browser.mjs @@ -678,12 +678,21 @@ async function verifyPreparationModalKeyboardIsolation(page) { const campaignModule = await import( '/heros_web/src/game/state/campaignState.ts' ); + const manualSaveModule = await import( + '/heros_web/src/game/state/manualSaveStorage.ts' + ); const snapshot = campaignModule.getCampaignState(); campaignModule.saveCampaignState(snapshot, 2); campaignModule.saveCampaignState(snapshot, 1); + const currentCampaign = + campaignModule.getCampaignState(); + manualSaveModule.writeManualSave(2, { + campaign: currentCampaign, + context: 'camp' + }); const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); - scene.campaign = campaignModule.getCampaignState(); + scene.campaign = currentCampaign; scene.showSortiePrep(); }); await page.waitForFunction( @@ -706,7 +715,15 @@ async function verifyPreparationModalKeyboardIsolation(page) { assert.equal( camp.campaign.activeSaveSlot, 1, - 'The 1 key must save to slot 1 instead of selecting the first preparation card.' + 'A protected manual save must preserve the active automatic slot.' + ); + let manualSave = + await readCampManualSave(page, 1); + assert.equal(manualSave?.context, 'camp'); + assert.equal( + manualSave?.selectedPriorityId, + 'companion', + 'The 1 key must save the current preparation to protected manual record 1.' ); await selectPreparationCard(page, 'equipment'); @@ -718,8 +735,15 @@ async function verifyPreparationModalKeyboardIsolation(page) { assertPreparationSelection(camp, 'equipment'); assert.equal( camp.campaign.activeSaveSlot, - 3, - 'The 3 key must save to slot 3 instead of selecting the third preparation card.' + 1, + 'Manual record 3 must not switch the active automatic slot.' + ); + manualSave = await readCampManualSave(page, 3); + assert.equal(manualSave?.context, 'camp'); + assert.equal( + manualSave?.selectedPriorityId, + 'equipment', + 'The 3 key must save to protected manual record 3 instead of selecting the third preparation card.' ); await selectPreparationCard(page, 'information'); @@ -758,8 +782,15 @@ async function verifyPreparationModalKeyboardIsolation(page) { assertPreparationSelection(camp, 'information'); assert.equal( camp.campaign.activeSaveSlot, - 2, - 'Enter must confirm the pending overwrite instead of confirming the focused preparation card.' + 1, + 'Confirming a manual overwrite must preserve the active automatic slot.' + ); + manualSave = await readCampManualSave(page, 2); + assert.equal(manualSave?.context, 'camp'); + assert.equal( + manualSave?.selectedPriorityId, + 'information', + 'Enter must overwrite protected manual record 2 instead of confirming the focused preparation card.' ); } @@ -2199,6 +2230,30 @@ async function readCampSaveModalState(page) { }); } +async function readCampManualSave(page, slot) { + return page.evaluate(async (requestedSlot) => { + const manualSaveModule = await import( + '/heros_web/src/game/state/manualSaveStorage.ts' + ); + const save = + manualSaveModule.readManualSave( + requestedSlot + ); + return save + ? { + slot: save.slot, + context: save.context, + activeSaveSlot: + save.campaign.activeSaveSlot, + selectedPriorityId: + save.campaign + .thirdCampPreparationSelection + ?.priorityId ?? null + } + : null; + }, slot); +} + async function openPreparationBrowser(page) { let lastState = await readCamp(page); for (let attempt = 0; attempt < 2; attempt += 1) { diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 5a73a28..f72a722 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -11515,6 +11515,7 @@ export class CampScene extends Phaser.Scene { private saveSlotConfirmObjects: Phaser.GameObjects.GameObject[] = []; private navigationBlockerObjects: Phaser.GameObjects.GameObject[] = []; private pendingSaveSlot?: number; + private handledCampShortcutEvents = new WeakSet(); private pendingVictoryRewardReport?: CampaignVictoryRewardReport; private victoryRewardLedgerReport?: CampaignVictoryRewardReport; private victorySettlementArrival?: VictorySettlementArrival; @@ -11841,7 +11842,7 @@ export class CampScene extends Phaser.Scene { musicVolume: this.campSoundscape.music.volume, ambienceVolume: this.campSoundscape.ambience.volume }); - this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey()); + this.input.keyboard?.on('keydown-ESC', (event: KeyboardEvent) => this.handleEscapeKey(event)); this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleSortieEnterKey(event)); this.input.keyboard?.on('keydown', (event: KeyboardEvent) => { if ( @@ -13409,6 +13410,25 @@ export class CampScene extends Phaser.Scene { panel.setDepth(depth); panel.setStrokeStyle(2, palette.gold, 0.86); panel.setInteractive(); + panel.on( + 'pointerdown', + ( + _pointer: Phaser.Input.Pointer, + _localX: number, + _localY: number, + event?: { stopPropagation: () => void } + ) => event?.stopPropagation() + ); + panel.on( + 'wheel', + ( + _pointer: Phaser.Input.Pointer, + _deltaX: number, + _deltaY: number, + _deltaZ: number, + event?: { stopPropagation: () => void } + ) => event?.stopPropagation() + ); this.trackCampSaveSlot(panel); const title = this.add.text(left + 22, top + 16, '수동 저장 기록', this.textStyle(22, '#f2e3bf', true)); @@ -13436,7 +13456,13 @@ export class CampScene extends Phaser.Scene { const close = this.add.text(left + width - 58, top + 18, '닫기', this.textStyle(14, '#d8b15f', true)); close.setDepth(depth + 2); close.setInteractive({ useHandCursor: true }); - close.on('pointerdown', () => { + close.on('pointerdown', ( + _pointer: Phaser.Input.Pointer, + _localX: number, + _localY: number, + event?: { stopPropagation: () => void } + ) => { + event?.stopPropagation(); soundDirector.playSelect(); this.hideCampSaveSlotPanel(); }); @@ -13505,7 +13531,15 @@ export class CampScene extends Phaser.Scene { saveLabel.setDepth(depth + 1); this.trackCampSaveSlot(saveLabel); - const run = () => this.requestSaveCampToSlot(summary.slot); + const run = ( + _pointer: Phaser.Input.Pointer, + _localX: number, + _localY: number, + event?: { stopPropagation: () => void } + ) => { + event?.stopPropagation(); + this.requestSaveCampToSlot(summary.slot); + }; [row, title, keycap, keyText, detail, meta, saveLabel].forEach((object) => { object.setInteractive({ useHandCursor: true }); object.on('pointerdown', run); @@ -13572,6 +13606,29 @@ export class CampScene extends Phaser.Scene { shade.setOrigin(0); shade.setDepth(depth - 1); shade.setInteractive(); + shade.on( + 'pointerdown', + ( + _pointer: Phaser.Input.Pointer, + _localX: number, + _localY: number, + event?: { stopPropagation: () => void } + ) => { + event?.stopPropagation(); + soundDirector.playSelect(); + this.hideCampSaveConfirm(); + } + ); + shade.on( + 'wheel', + ( + _pointer: Phaser.Input.Pointer, + _deltaX: number, + _deltaY: number, + _deltaZ: number, + event?: { stopPropagation: () => void } + ) => event?.stopPropagation() + ); this.trackCampSaveConfirm(shade); const panel = this.add.rectangle(x, y, width, height, 0x111a24, 0.99); @@ -13579,6 +13636,25 @@ export class CampScene extends Phaser.Scene { panel.setDepth(depth); panel.setStrokeStyle(2, palette.gold, 0.9); panel.setInteractive(); + panel.on( + 'pointerdown', + ( + _pointer: Phaser.Input.Pointer, + _localX: number, + _localY: number, + event?: { stopPropagation: () => void } + ) => event?.stopPropagation() + ); + panel.on( + 'wheel', + ( + _pointer: Phaser.Input.Pointer, + _deltaX: number, + _deltaY: number, + _deltaZ: number, + event?: { stopPropagation: () => void } + ) => event?.stopPropagation() + ); this.trackCampSaveConfirm(panel); const title = this.add.text(x + 22, y + 18, `슬롯 ${summary.slot} 덮어쓰기`, this.textStyle(20, '#f2e3bf', true)); @@ -13608,7 +13684,15 @@ export class CampScene extends Phaser.Scene { confirm.setPadding(14, 5, 14, 5); confirm.setBackgroundColor('#6d4420'); confirm.setInteractive({ useHandCursor: true }); - confirm.on('pointerdown', () => this.confirmCampSaveOverwrite()); + confirm.on('pointerdown', ( + _pointer: Phaser.Input.Pointer, + _localX: number, + _localY: number, + event?: { stopPropagation: () => void } + ) => { + event?.stopPropagation(); + this.confirmCampSaveOverwrite(); + }); this.trackCampSaveConfirm(confirm); const cancel = this.add.text(x + width - 70, y + 126, '취소', this.textStyle(14, '#d8b15f', true)); @@ -13616,7 +13700,13 @@ export class CampScene extends Phaser.Scene { cancel.setPadding(14, 5, 14, 5); cancel.setBackgroundColor('#1a2630'); cancel.setInteractive({ useHandCursor: true }); - cancel.on('pointerdown', () => { + cancel.on('pointerdown', ( + _pointer: Phaser.Input.Pointer, + _localX: number, + _localY: number, + event?: { stopPropagation: () => void } + ) => { + event?.stopPropagation(); soundDirector.playSelect(); this.hideCampSaveConfirm(); }); @@ -14267,7 +14357,31 @@ export class CampScene extends Phaser.Scene { return true; } - private handleEscapeKey() { + private acceptCampShortcutEvent( + event: KeyboardEvent, + shortcut: 'escape' | 'enter' + ) { + const matches = + shortcut === 'escape' + ? event.key === 'Escape' || event.code === 'Escape' + : event.key === 'Enter' || + event.code === 'Enter' || + event.code === 'NumpadEnter'; + if ( + !matches || + event.repeat || + this.handledCampShortcutEvents.has(event) + ) { + return false; + } + this.handledCampShortcutEvents.add(event); + return true; + } + + private handleEscapeKey(event: KeyboardEvent) { + if (!this.acceptCampShortcutEvent(event, 'escape')) { + return; + } if (this.navigationPending) { return; } @@ -14335,7 +14449,10 @@ export class CampScene extends Phaser.Scene { } private handleSortieEnterKey(event: KeyboardEvent) { - if (event.repeat || this.navigationPending) { + if ( + !this.acceptCampShortcutEvent(event, 'enter') || + this.navigationPending + ) { return; } if (