From 1f805a718df3beda6754a91f1ddc8e5d2511f9b7 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Wed, 29 Jul 2026 04:30:46 +0900 Subject: [PATCH] fix: prevent dialogue input rebound --- .../verify-city-stay-checkpoint-browser.mjs | 138 +++++++++++++++++- src/game/scenes/CityStayScene.ts | 11 ++ 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/scripts/verify-city-stay-checkpoint-browser.mjs b/scripts/verify-city-stay-checkpoint-browser.mjs index c0ace5f..8b6e8cd 100644 --- a/scripts/verify-city-stay-checkpoint-browser.mjs +++ b/scripts/verify-city-stay-checkpoint-browser.mjs @@ -748,6 +748,10 @@ async function openResonanceChoice(page) { } async function openShop(page) { + await waitForCityInteractionIdle( + page, + fixture.marketActorId + ); let interacted = false; for (let attempt = 0; attempt < 8; attempt += 1) { interacted = await page.evaluate((actorId) => @@ -761,10 +765,18 @@ async function openShop(page) { } await page.waitForTimeout(120); } + const diagnostic = interacted + ? null + : await readCityInteractionDiagnostic( + page, + fixture.marketActorId + ); assert.equal( interacted, true, - `${renderer}: could not open the city shop.` + `${renderer}: could not open the city shop: ${JSON.stringify( + diagnostic + )}` ); await waitForShopOpen(page); const city = await readCity(page); @@ -864,17 +876,115 @@ function assertDialogue( async function finishCityDialogue(page) { for (let attempt = 0; attempt < 12; attempt += 1) { const city = await readCity(page); - if (!city?.dialogue?.active) { + if ( + city?.ready === true && + city.dialogue?.active === false + ) { + await waitForCityInteractionIdle(page); return; } - await page.waitForTimeout(150); - await page.keyboard.press('e'); + if (city?.dialogue?.active === true) { + await page.waitForTimeout(150); + await page.keyboard.press('e'); + } else { + await page.waitForTimeout(150); + } } throw new Error( `${renderer}: city dialogue did not finish.` ); } +async function waitForCityInteractionIdle( + page, + targetActorId +) { + const expected = { targetActorId }; + const waitForIdle = () => + page.waitForFunction( + ({ targetActorId: actorId }) => { + const debug = window.__HEROS_DEBUG__; + const city = debug?.cityStay?.(); + return ( + debug + ?.activeScenes?.() + .includes('CityStayScene') === true && + city?.scene === 'CityStayScene' && + city.ready === true && + city.navigationPending === false && + city.dialogue?.active === false && + city.shop?.open === false && + city.choice?.open === false && + city.campaignObjectiveJournal?.view?.visible === + false && + (!actorId || + city.actors?.some( + ({ id }) => id === actorId + )) + ); + }, + expected, + { timeout: 30000 } + ); + await waitForIdle(); + await afterTwoFrames(page); + const stable = await readCityInteractionDiagnostic( + page, + targetActorId + ); + assert.equal( + stable.idle, + true, + `${renderer}: city interaction became blocked after reaching idle; the closing key may have reopened a modal: ${JSON.stringify( + stable + )}` + ); +} + +async function readCityInteractionDiagnostic( + page, + targetActorId +) { + return page.evaluate((actorId) => { + const debug = window.__HEROS_DEBUG__; + const scene = debug?.scene('CityStayScene'); + const city = debug?.cityStay?.() ?? null; + const activeScenes = debug?.activeScenes?.() ?? []; + const actor = + city?.actors?.find(({ id }) => id === actorId) ?? + null; + const objectiveJournalOpen = + city?.campaignObjectiveJournal?.view?.visible ?? + null; + const idle = + activeScenes.includes('CityStayScene') && + city?.scene === 'CityStayScene' && + city.ready === true && + city.navigationPending === false && + city.dialogue?.active === false && + city.shop?.open === false && + city.choice?.open === false && + objectiveJournalOpen === false && + (!actorId || Boolean(actor)); + return { + idle, + activeScenes, + sceneActive: + scene?.scene?.isActive?.() ?? false, + scenePaused: + scene?.scene?.isPaused?.() ?? false, + ready: city?.ready ?? null, + navigationPending: + city?.navigationPending ?? null, + dialogue: city?.dialogue ?? null, + shopOpen: city?.shop?.open ?? null, + choiceOpen: city?.choice?.open ?? null, + objectiveJournalOpen, + actor + }; + }, targetActorId); +} + async function finishCityDialogueToCamp(page) { for (let attempt = 0; attempt < 12; attempt += 1) { const activeScenes = await page.evaluate( @@ -1229,7 +1339,7 @@ async function ensureLocalServer(url) { } await delay(250); } - child.kill(); + await stopServerProcess(child); throw new Error( `Vite server did not start at ${url}\n${stderr.join('')}` ); @@ -1247,11 +1357,25 @@ async function canReach(url) { } async function stopServerProcess(child) { - if (!child || child.killed) { + if ( + !child || + child.exitCode !== null || + child.signalCode !== null + ) { return; } + const exited = new Promise((resolve) => + child.once('exit', resolve) + ); child.kill(); - await delay(80); + await Promise.race([exited, delay(5000)]); + if ( + child.exitCode === null && + child.signalCode === null + ) { + child.kill('SIGKILL'); + await Promise.race([exited, delay(2000)]); + } } function delay(ms) { diff --git a/src/game/scenes/CityStayScene.ts b/src/game/scenes/CityStayScene.ts index aabf183..21fa64e 100644 --- a/src/game/scenes/CityStayScene.ts +++ b/src/game/scenes/CityStayScene.ts @@ -214,6 +214,7 @@ export class CityStayScene extends Phaser.Scene { private navigationPending = false; private inputReadyAt = Number.POSITIVE_INFINITY; private autoDialogueInputReadyAt = 0; + private interactionInputReadyAt = 0; private stepIndex = 0; private lastStepAt = 0; private lastNotice = ''; @@ -367,6 +368,9 @@ export class CityStayScene extends Phaser.Scene { } if (this.consumeInteractionRequest()) { + if (time < this.interactionInputReadyAt) { + return; + } this.tryInteract(); return; } @@ -677,6 +681,7 @@ export class CityStayScene extends Phaser.Scene { this.navigationPending = false; this.inputReadyAt = Number.POSITIVE_INFINITY; this.autoDialogueInputReadyAt = 0; + this.interactionInputReadyAt = 0; this.explorationInput = undefined; this.stepIndex = 0; this.lastStepAt = 0; @@ -3220,6 +3225,9 @@ export class CityStayScene extends Phaser.Scene { this.dialogueState = undefined; this.dialoguePanel?.setVisible(false); this.stopPlayerMovement(); + this.interactionInputReadyAt = + this.time.now + inputCarryoverGuardMs; + this.explorationInput?.discardInteractionRequest(); this.restoreActorDirection(sourceTargetId); const checkpointCommitted = onComplete?.() === true; if (!checkpointCommitted) { @@ -3233,6 +3241,9 @@ export class CityStayScene extends Phaser.Scene { this.dialogueState = undefined; this.dialoguePanel?.setVisible(false); this.stopPlayerMovement(); + this.interactionInputReadyAt = + this.time.now + inputCarryoverGuardMs; + this.explorationInput?.discardInteractionRequest(); this.restoreActorDirection(sourceTargetId); this.persistExplorationCheckpoint(true); this.refreshInteractionPrompt();