diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index 9eb62ee..94a81a9 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -271,12 +271,31 @@ RPG로 다듬는다. - Canvas와 WebGL 모두 1920x1080 CSS viewport, 100% 확대, DPR 1에서 포인터 입력 없이 메뉴와 4단계 전투 조작을 자동 검증한다. -다음 묶음은 첫 승리 직후 같은 보상 정보가 전투 결과·후일담·군영 도착· -전투 보고에 연속 반복되는 흐름을 정리한다. 각 화면은 `무엇을 얻었는가`, -`인물이 어떻게 받아들이는가`, `다음에 무엇을 할 것인가` 중 한 역할만 -맡게 해 정보 피로를 줄이되, 상세 기록은 원할 때 다시 볼 수 있게 남긴다. -그다음에는 조기 턴 종료 확인창에 적의 대상별 공격 수뿐 아니라 예상 피해와 -명중 위험을 함께 요약해 마지막 결정에 필요한 정보를 보존한다. +### 완료한 14차 묶음: 한 번만 기뻐하고 필요할 때 다시 보는 승전 정산 + +- 레벨 종료 뒤 여러 보상·알림·추가 클릭을 겹치면 실제 이탈 지점이 될 수 + 있다는 [OMG: TD! 포스트모템](https://www.gamedeveloper.com/business/postmortem-red-rocket-games-omg-td-)과, + 한 화면은 한 가지 플레이어 요구에 집중시키라는 + [Maxis의 GDC UX 설계 자료](https://media.gdcvault.com/gdc2017/Presentations/rybicki-Holistic_UX_design-PDF.pdf)를 + 현재 첫 승리 흐름의 기준으로 삼았다. +- 전투 결과는 금화·물자·성장과 승리 성취를 확인하는 유일한 필수 정산으로 + 남긴다. 상단의 중복 금액은 `달성 목표`로 바꾸고, 간옹 이름과 다음 전장 + 제목은 후일담보다 먼저 공개하지 않도록 수량만 알린다. +- 첫 승리 후일담 5페이지에서는 시스템 보상 스트립을 없애고 마을의 변화, + 관우·장비의 반응, 간옹의 등장과 다음 사건만 이야기한다. +- 군영 도착은 6카드 모달로 흐름을 막지 않는다. 정산 완료를 짧게 알리고 + 장비·보급·합류·해금의 `NEW` 상태는 실제 사용 화면까지 유지한다. +- 항상 보이는 전투 보고는 승패·턴·목표·MVP만 요약한다. 정확한 금액과 + 전체 획득 내역은 `보상 장부`를 눌렀을 때만 다시 열며, 장부의 기본 행동은 + 군영 탐색과 경쟁하지 않는 `장부 닫기` 하나로 고정한다. +- 첫 두 출진 준비의 `지난 보상` 반복을 제거하고 다음 전투의 예상 보상만 + 남겼다. 결과→후일담→군영→장부 역할은 정적 계약과 1920x1080 브라우저 + 회귀 검증으로 고정한다. + +다음 묶음은 조기 턴 종료 확인창에 적의 대상별 공격 수뿐 아니라 예상 피해와 +명중 위험을 함께 요약해 마지막 결정에 필요한 정보를 보존한다. 그 뒤에는 +[Xbox 목표 명료성 지침](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/109)에 +맞춰 현재 목표와 완료 목표의 재확인 위치를 캠페인 전반에서 다시 감사한다. ## 장기 개선 순서 diff --git a/package.json b/package.json index 4de1300..3f11da3 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "verify:prologue-village": "node scripts/verify-prologue-village-data.mjs", "verify:first-battle-camp-followup": "node scripts/verify-first-battle-camp-followup.mjs", "verify:first-battle-camaraderie": "node scripts/verify-first-battle-camaraderie-memory.mjs", + "verify:first-victory-debrief": "node scripts/verify-first-victory-debrief-hierarchy.mjs", "verify:first-pursuit-scout": "node scripts/verify-first-pursuit-scout-memory.mjs", "verify:first-pursuit-camp:browser": "node scripts/verify-first-pursuit-camp-exploration-browser.mjs", "verify:second-battle-relief": "node scripts/verify-second-battle-relief-exploration.mjs", diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index c223a86..582fcb0 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -1868,29 +1868,34 @@ async function enterCampaignCampFromBattleResult(page, battle, route) { camp?.scene === 'CampScene' && camp?.campBattleId === battleId && camp?.campaign?.step === campStep && - reward?.visible === true && + camp?.victorySettlementArrival?.battleId === battleId && + camp.victorySettlementArrival.blocking === false && + reward?.role === 'on-demand-ledger' && + reward?.visible === false && reward?.battleId === battleId && - reward?.cards?.length === 6 && - reward.cards.every((card) => card.bounds && (card.actionId === null || card.interactive === true)) && - reward?.actions?.length === 4 && - reward.actions.every((action) => action.bounds && action.interactive === true) + reward?.toggleInteractive === true && + reward?.toggleButtonBounds && + reward?.cards?.length === 0 && + reward?.actions?.length === 0 ); }, { battleId: battle.id, campStep: route?.campStep }, { timeout: 90000 } ); - console.log(`QA intermission ${battle.no}: reward camp ready`); + const rewardLedger = await revealCampVictoryRewardLedger(page, battle.id); + console.log(`QA intermission ${battle.no}: reward ledger opened`); - return page.evaluate(() => { - const camp = window.__HEROS_DEBUG__?.camp?.(); - return { - battleId: camp?.campBattleId ?? null, - step: camp?.campaign?.step ?? null, - rewardBattleId: camp?.victoryRewardAcknowledgement?.battleId ?? null, - rewardCardIds: camp?.victoryRewardAcknowledgement?.cards?.map((card) => card.id) ?? [], - rewardActionIds: camp?.victoryRewardAcknowledgement?.actions?.map((action) => action.id) ?? [] - }; - }); + return { + battleId: rewardLedger.camp?.campBattleId ?? null, + step: rewardLedger.camp?.campaign?.step ?? null, + settlementBlocking: rewardLedger.arrival?.blocking ?? null, + rewardRole: rewardLedger.reward?.role ?? null, + rewardInitiallyVisible: rewardLedger.initialReward?.visible ?? null, + rewardToggleInteractive: rewardLedger.initialReward?.toggleInteractive ?? null, + rewardBattleId: rewardLedger.reward?.battleId ?? null, + rewardCardIds: rewardLedger.reward?.cards?.map((card) => card.id) ?? [], + rewardActionIds: rewardLedger.reward?.actions?.map((action) => action.id) ?? [] + }; } async function enterFinalCampFromBattleResult(page) { @@ -1926,19 +1931,29 @@ async function enterFinalCampFromBattleResult(page) { camp?.scene === 'CampScene' && camp?.campBattleId === 'sixty-sixth-battle-wuzhang-final' && camp?.campSkin?.campaignStep === 'sixty-sixth-camp' && - camp?.victoryRewardAcknowledgement?.visible === true && + camp?.victorySettlementArrival?.battleId === 'sixty-sixth-battle-wuzhang-final' && + camp.victorySettlementArrival.blocking === false && + camp?.victoryRewardAcknowledgement?.role === 'on-demand-ledger' && + camp?.victoryRewardAcknowledgement?.visible === false && camp?.victoryRewardAcknowledgement?.battleId === 'sixty-sixth-battle-wuzhang-final' && - camp?.victoryRewardAcknowledgement?.cards?.length === 6 && - camp.victoryRewardAcknowledgement.cards.every((card) => card.bounds && (card.actionId === null || card.interactive === true)) && - camp?.victoryRewardAcknowledgement?.actions?.length === 4 && - camp.victoryRewardAcknowledgement.actions.every((action) => action.bounds && action.interactive === true) + camp?.victoryRewardAcknowledgement?.toggleInteractive === true && + camp?.victoryRewardAcknowledgement?.toggleButtonBounds && + camp?.victoryRewardAcknowledgement?.cards?.length === 0 && + camp?.victoryRewardAcknowledgement?.actions?.length === 0 ); }, undefined, { timeout: 90000 }); - const finalCamp = await page.evaluate(() => ({ - activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], - camp: window.__HEROS_DEBUG__?.camp?.() ?? null - })); + const finalRewardLedger = await revealCampVictoryRewardLedger( + page, + 'sixty-sixth-battle-wuzhang-final' + ); + const finalCamp = { + activeScenes: finalRewardLedger.activeScenes, + camp: finalRewardLedger.camp, + settlementBlocking: finalRewardLedger.arrival?.blocking ?? null, + rewardInitiallyVisible: finalRewardLedger.initialReward?.visible ?? null, + rewardToggleInteractive: finalRewardLedger.initialReward?.toggleInteractive ?? null + }; const closeAction = finalCamp.camp?.victoryRewardAcknowledgement?.actions?.find((action) => action.id === 'close'); if (!closeAction?.bounds) { throw new Error(`Final reward panel did not expose its close action: ${JSON.stringify(finalCamp)}`); @@ -1998,6 +2013,84 @@ async function readBattleResultCtaPoint(page) { return point; } +async function revealCampVictoryRewardLedger(page, expectedBattleId) { + const initial = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], + camp: window.__HEROS_DEBUG__?.camp?.() ?? null + })); + const arrival = initial.camp?.victorySettlementArrival; + const initialReward = initial.camp?.victoryRewardAcknowledgement; + if ( + arrival?.battleId !== expectedBattleId || + arrival.blocking !== false || + !arrival.settledBattleIds?.includes(expectedBattleId) || + arrival.pendingCategories?.some((category) => category === 'gold' || category === 'reputation') || + !arrival.passiveCategoriesAcknowledged?.every( + (category) => category === 'gold' || category === 'reputation' + ) || + initialReward?.role !== 'on-demand-ledger' || + initialReward.available !== true || + initialReward.visible !== false || + initialReward.battleId !== expectedBattleId || + initialReward.toggleInteractive !== true || + !initialReward.toggleButtonBounds || + initialReward.cards?.length !== 0 || + initialReward.actions?.length !== 0 + ) { + throw new Error( + `Expected a nonblocking settlement with a closed on-demand reward ledger for ${expectedBattleId}: ${JSON.stringify(initial)}` + ); + } + + const togglePoint = await readSceneBoundsPoint( + page, + 'CampScene', + initialReward.toggleButtonBounds + ); + await page.mouse.click(togglePoint.x, togglePoint.y); + await page.waitForFunction((battleId) => { + const reward = window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement; + const primaryActions = reward?.actions?.filter((action) => action.primary) ?? []; + return ( + reward?.role === 'on-demand-ledger' && + reward?.visible === true && + reward?.battleId === battleId && + reward?.cards?.length === 6 && + reward.cards.every((card) => card.bounds && (card.actionId === null || card.interactive === true)) && + reward?.actions?.map((action) => action.id).join(',') === 'equipment,supplies,sortie,close' && + reward.actions.every((action) => action.bounds && action.interactive === true) && + primaryActions.length === 1 && + primaryActions[0]?.id === 'close' + ); + }, expectedBattleId, { timeout: 30000 }); + + const revealed = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], + camp: window.__HEROS_DEBUG__?.camp?.() ?? null + })); + const reward = revealed.camp?.victoryRewardAcknowledgement; + const pendingCategories = new Set(arrival.pendingCategories ?? []); + const newCategoryIds = new Set( + reward?.cards?.filter((card) => card.new).map((card) => card.id) ?? [] + ); + if (![...pendingCategories].every((category) => newCategoryIds.has(category))) { + throw new Error( + `Pending categories lost their NEW card markers in ${expectedBattleId}: ${JSON.stringify({ + pendingCategories: [...pendingCategories], + newCategoryIds: [...newCategoryIds], + reward + })}` + ); + } + + return { + ...revealed, + arrival, + initialReward, + reward + }; +} + async function readSceneBoundsPoint(page, sceneKey, bounds) { const point = await page.evaluate( ({ requestedSceneKey, logicalBounds }) => { @@ -2065,9 +2158,13 @@ async function assertCompleteCampaignSave(page, results, finalCamp) { return ( result.intermissionCamp?.battleId !== result.id || result.intermissionCamp?.step !== route?.campStep || + result.intermissionCamp?.settlementBlocking !== false || + result.intermissionCamp?.rewardRole !== 'on-demand-ledger' || + result.intermissionCamp?.rewardInitiallyVisible !== false || + result.intermissionCamp?.rewardToggleInteractive !== true || result.intermissionCamp?.rewardBattleId !== result.id || result.intermissionCamp?.rewardCardIds?.length !== 6 || - result.intermissionCamp?.rewardActionIds?.length !== 4 + result.intermissionCamp?.rewardActionIds?.join(',') !== 'equipment,supplies,sortie,close' ); }); const finalReward = finalCamp?.camp?.victoryRewardAcknowledgement; @@ -2088,9 +2185,13 @@ async function assertCompleteCampaignSave(page, results, finalCamp) { finalCamp?.camp?.campBattleId !== expectedIds.at(-1) || finalCamp?.camp?.campSkin?.campaignStep !== 'sixty-sixth-camp' || finalReward?.battleId !== expectedIds.at(-1) || + finalReward?.role !== 'on-demand-ledger' || finalReward?.visible !== true || finalReward?.cards?.length !== 6 || - finalReward?.actions?.length !== 4 || + finalReward?.actions?.map((action) => action.id).join(',') !== 'equipment,supplies,sortie,close' || + finalCamp?.settlementBlocking !== false || + finalCamp?.rewardInitiallyVisible !== false || + finalCamp?.rewardToggleInteractive !== true || finalCamp?.rewardCloseVerified !== true || finalCamp?.campAfterClose?.victoryRewardAcknowledgement?.visible !== false ) { diff --git a/scripts/verify-audiovisual-feedback.mjs b/scripts/verify-audiovisual-feedback.mjs index fa9d044..84a43a1 100644 --- a/scripts/verify-audiovisual-feedback.mjs +++ b/scripts/verify-audiovisual-feedback.mjs @@ -70,7 +70,11 @@ assert.match(tacticalInitiative, /soundDirector\.playRecoveryCue\(\)/); assert.match(campSupply, /soundDirector\.playRecoveryCue\(\)/); const victoryReward = privateMethodBody(campSource, 'showVictoryRewardAcknowledgement'); -assert.equal(countMatches(victoryReward, /soundDirector\.playRewardRevealCue\(\)/g), 1, 'victory reward should reveal once'); +assert.equal( + countMatches(victoryReward, /soundDirector\.playRewardRevealCue\(\)/g), + 0, + 'opening the on-demand reward ledger must not replay the battle result reward celebration' +); const progressChapterDetail = privateMethodBody(campSource, 'renderProgressChapterDetail'); assert.match(progressChapterDetail, /const battleTitleWidth = 116/); diff --git a/scripts/verify-city-stay-browser.mjs b/scripts/verify-city-stay-browser.mjs index fdb20e9..6fbc7b8 100644 --- a/scripts/verify-city-stay-browser.mjs +++ b/scripts/verify-city-stay-browser.mjs @@ -473,11 +473,27 @@ function verifyGatewayLayout(camp) { camp.cityStay.panelBounds, 'City exploration gateway button' ); + const rewardLedger = camp.victoryRewardAcknowledgement; assert.equal( - camp.victoryRewardAcknowledgement.visible, - false, - 'The acknowledged battle reward must not obscure the city gateway.' + camp.victorySettlementArrival, + null, + 'An already acknowledged and dismissed victory must not repeat its settlement arrival notice.' ); + assert.equal(rewardLedger.role, 'on-demand-ledger'); + assert.equal(rewardLedger.available, true); + assert.equal( + rewardLedger.visible, + false, + 'The on-demand reward ledger must not obscure the city gateway.' + ); + assert.equal(rewardLedger.toggleInteractive, true); + assertBoundsInside( + rewardLedger.toggleButtonBounds, + viewportBounds, + 'City-camp reward ledger toggle' + ); + assert.deepEqual(rewardLedger.cards, []); + assert.deepEqual(rewardLedger.actions, []); } async function enterCityFromGateway(page, gatewayState, expectedCityStayId) { diff --git a/scripts/verify-first-pursuit-camp-exploration-browser.mjs b/scripts/verify-first-pursuit-camp-exploration-browser.mjs index 897a95c..0146cc6 100644 --- a/scripts/verify-first-pursuit-camp-exploration-browser.mjs +++ b/scripts/verify-first-pursuit-camp-exploration-browser.mjs @@ -97,25 +97,25 @@ try { ({ expectedVisitId }) => { const debug = window.__HEROS_DEBUG__; const camp = debug?.camp?.(); - const reward = - camp?.victoryRewardAcknowledgement; - const guided = reward?.actions?.find( - (action) => action.id === 'guided' - ); + const arrival = camp?.victorySettlementArrival; + const reward = camp?.victoryRewardAcknowledgement; return ( debug?.activeScenes?.().includes('CampScene') && camp?.campaign?.step === 'first-camp' && - reward?.visible === true && - reward.actions - .map((action) => action.id) - .join(',') === - 'equipment,supplies,guided,sortie,close' && - guided?.primary === true && - guided?.label === '북문 정찰막 둘러보기' && - guided?.interactive === true && + arrival?.battleId === 'first-battle-zhuo-commandery' && + arrival.blocking === false && + reward?.role === 'on-demand-ledger' && + reward.available === true && + reward.visible === false && + reward.toggleInteractive === true && + reward.toggleButtonBounds && + reward.cards?.length === 0 && + reward.actions?.length === 0 && camp?.sortieCommand?.guidedKind === 'visit' && camp.sortieCommand.targetId === expectedVisitId && camp.sortieCommand.label === '정찰막 둘러보기' && + camp.sortieCommand.interactive === true && + camp.sortieCommand.bounds && camp.sortieCommand.bypass?.label === '바로 출진' && camp.sortieCommand.bypass?.interactive === true ); @@ -126,34 +126,155 @@ try { const guidedCamp = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.() ); - const guidedReward = - guidedCamp.victoryRewardAcknowledgement; - const guidedAction = guidedReward.actions.find( - (action) => action.id === 'guided' + const settlementArrival = guidedCamp.victorySettlementArrival; + const closedRewardLedger = guidedCamp.victoryRewardAcknowledgement; + assert.deepEqual( + settlementArrival.pendingCategories.filter( + (category) => category === 'gold' || category === 'reputation' + ), + [], + 'Passive reward categories must be acknowledged on arrival without consuming destination-specific NEW categories.' ); - assert.equal( - guidedReward.actions.filter((action) => action.primary) - .length, - 1 - ); - assert.equal( - guidedReward.actions.find( - (action) => action.id === 'sortie' - )?.label, - '바로 출진' + assert( + settlementArrival.passiveCategoriesAcknowledged.every( + (category) => category === 'gold' || category === 'reputation' + ) ); + assert(settlementArrival.settledBattleIds.includes(sourceBattleId)); assertBoundsInsideViewport( - guidedAction.bounds, - 'first-camp guided reward action' - ); - await captureStableScreenshot( - page, - 'dist/verification-first-pursuit-camp-guided-reward.png' + closedRewardLedger.toggleButtonBounds, + 'first-camp reward ledger toggle' ); await clickNamedSceneBounds( page, 'CampScene', - guidedAction.bounds + closedRewardLedger.toggleButtonBounds + ); + await page.waitForFunction(() => { + const reward = + window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement; + const primaryActions = + reward?.actions?.filter((action) => action.primary) ?? []; + return ( + reward?.role === 'on-demand-ledger' && + reward?.visible === true && + reward?.cards?.length === 6 && + reward.cards.every( + (card) => + card.bounds && + (card.actionId === null || card.interactive === true) + ) && + reward?.actions + ?.map((action) => action.id) + .join(',') === 'equipment,supplies,sortie,close' && + reward.actions.every( + (action) => action.bounds && action.interactive === true + ) && + primaryActions.length === 1 && + primaryActions[0]?.id === 'close' + ); + }); + const openLedgerCamp = await page.evaluate(() => + window.__HEROS_DEBUG__?.camp?.() + ); + const openRewardLedger = + openLedgerCamp.victoryRewardAcknowledgement; + const pendingCategorySet = new Set( + settlementArrival.pendingCategories + ); + const newCardCategorySet = new Set( + openRewardLedger.cards + .filter((card) => card.new) + .map((card) => card.id) + ); + assert( + [...pendingCategorySet].every((category) => + newCardCategorySet.has(category) + ), + `Pending reward categories must retain NEW ledger cards: ${JSON.stringify({ + pendingCategories: [...pendingCategorySet], + newCards: [...newCardCategorySet] + })}` + ); + assert.equal( + openRewardLedger.actions.filter((action) => action.primary) + .length, + 1 + ); + assert.equal( + openRewardLedger.actions.find( + (action) => action.id === 'sortie' + )?.label, + '출진 준비' + ); + const closeLedgerAction = openRewardLedger.actions.find( + (action) => action.id === 'close' + ); + assertBoundsInsideViewport( + closeLedgerAction.bounds, + 'first-camp reward ledger close action' + ); + await captureStableScreenshot( + page, + 'dist/verification-first-pursuit-camp-reward-ledger.png' + ); + await clickNamedSceneBounds( + page, + 'CampScene', + closeLedgerAction.bounds + ); + await page.waitForFunction(() => + window.__HEROS_DEBUG__?.camp?.() + ?.victoryRewardAcknowledgement?.visible === false + ); + let afterLedgerClose = await page.evaluate(() => + window.__HEROS_DEBUG__?.camp?.() + ); + await clickNamedSceneBounds( + page, + 'CampScene', + afterLedgerClose.victoryRewardAcknowledgement.toggleButtonBounds + ); + await page.waitForFunction(() => + window.__HEROS_DEBUG__?.camp?.() + ?.victoryRewardAcknowledgement?.visible === true + ); + const reopenedRewardLedger = await page.evaluate(() => + window.__HEROS_DEBUG__?.camp?.() + ?.victoryRewardAcknowledgement + ); + assert.deepEqual( + reopenedRewardLedger.cards + .filter((card) => card.new) + .map((card) => card.id), + openRewardLedger.cards + .filter((card) => card.new) + .map((card) => card.id), + 'Closing the ledger must not consume destination-specific NEW categories.' + ); + const reopenedCloseAction = reopenedRewardLedger.actions.find( + (action) => action.id === 'close' + ); + await clickNamedSceneBounds( + page, + 'CampScene', + reopenedCloseAction.bounds + ); + await page.waitForFunction(() => + window.__HEROS_DEBUG__?.camp?.() + ?.victoryRewardAcknowledgement?.visible === false + ); + afterLedgerClose = await page.evaluate(() => + window.__HEROS_DEBUG__?.camp?.() + ); + assertBoundsInsideViewport( + afterLedgerClose.sortieCommand.bounds, + 'first-camp guided sortie command' + ); + await clickNamedSceneBounds( + page, + 'CampScene', + afterLedgerClose.sortieCommand.bounds ); await waitForExplorationReady(page); await page.waitForTimeout(380); @@ -633,7 +754,7 @@ try { console.log( `Verified the first-pursuit camp exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + `DPR ${desktopBrowserDeviceScaleFactor}: dedicated Jian Yong SD art, fixed NPC positions, keyboard and pointer movement, ` + - 'first-victory guided reward routing, distance-gated interaction, face portraits, two real choices, one-time resonance/item rewards, duplicate protection, ' + + 'nonblocking victory settlement, on-demand reward ledger, guided camp routing, distance-gated interaction, face portraits, two real choices, one-time resonance/item rewards, duplicate protection, ' + 'CampScene return, and current/slot-1 save persistence.' ); } finally { diff --git a/scripts/verify-first-victory-debrief-hierarchy.mjs b/scripts/verify-first-victory-debrief-hierarchy.mjs new file mode 100644 index 0000000..7fa66fd --- /dev/null +++ b/scripts/verify-first-victory-debrief-hierarchy.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8'); +const storySource = readFileSync('src/game/scenes/StoryScene.ts', 'utf8'); +const campSource = readFileSync('src/game/scenes/CampScene.ts', 'utf8'); +const campaignFlowSource = readFileSync('src/game/data/campaignFlow.ts', 'utf8'); + +const battleResult = methodBlock(battleSource, 'private showBattleResult('); +assert.match( + battleResult, + /renderResultMetric\('달성 목표', `\$\{achievedObjectiveCount\} \/ \$\{objectives\.length\}`/, + 'Battle result should use the fourth headline metric for objective completion.' +); +assert.doesNotMatch( + battleResult, + /renderResultMetric\('전투 보상'/, + 'Battle result should not repeat the same gold total in its headline metrics and reward cards.' +); + +const battleSubtitle = methodBlock(battleSource, 'private resultSubtitle('); +assert.match( + battleSubtitle, + /승리 후일담으로 이어집니다/, + 'Victory subtitle should direct the player to the aftermath.' +); +assert.doesNotMatch( + battleSubtitle, + /unlocks|recruits/, + 'Victory subtitle must not reveal recruits or the next battlefield before the aftermath.' +); + +const battleRewards = methodBlock(battleSource, 'private renderResultRewardPanel('); +assert.match( + battleRewards, + /신규 합류 \$\{rewards\.recruits\.length\}명 · 새 전장 \$\{rewards\.unlocks\.length\}곳/, + 'Battle result should summarize future progression without naming story reveals.' +); +assert.doesNotMatch( + battleRewards, + /rewards\.recruits\.map\([^)]*name|rewards\.unlocks\.map\([^)]*title/s, + 'Battle result must not name recruits or unlocked battlefields before the aftermath.' +); + +const storyRewardDisplay = methodBlock(storySource, 'private rewardDisplayForPage('); +assert.match(storyRewardDisplay, /source: 'campaign'/, 'First-victory aftermath should retain its campaign report link.'); +assert.match(storyRewardDisplay, /battleId: report\.battleId/, 'First-victory aftermath should identify the source battle.'); +assert.match(storyRewardDisplay, /rewardGold: null/, 'First-victory aftermath should not repeat exact gold.'); +assert.match(storyRewardDisplay, /items: \[\]/, 'First-victory aftermath should not repeat system reward cards.'); +assert.doesNotMatch( + storyRewardDisplay, + /campaignVictoryRewardPresentation|campaignRewards/, + 'First-victory aftermath should not redistribute reward categories across dialogue pages.' +); + +assert.match( + campSource, + /this\.settleVictoryRewardArrival\(this\.pendingVictoryRewardReport\)/, + 'Camp arrival should settle a pending victory without opening a blocking ledger.' +); +assert.doesNotMatch( + campSource, + /delayedCall\(0,\s*\(\) => this\.showVictoryRewardAcknowledgement\(\)\)/, + 'Camp arrival must not auto-open the reward ledger.' +); + +const campSettlement = methodBlock(campSource, 'private settleVictoryRewardArrival('); +assert.match(campSettlement, /dismissCampaignVictoryRewardNotice/, 'Arrival should dismiss the one-time reward notice.'); +assert.match(campSettlement, /category === 'gold' \|\| category === 'reputation'/, 'Arrival should acknowledge passive rewards.'); +assert.match(campSettlement, /세부 획득 내역은 하단 전투 보고의 보상 장부/, 'Arrival should point to optional detail.'); +assert.match(campSettlement, /pendingCategories:/, 'Arrival should preserve use-location NEW categories.'); + +const rewardLedger = methodBlock(campSource, 'private showVictoryRewardAcknowledgement('); +assert.match(rewardLedger, /reportOverride\?: CampaignVictoryRewardReport/, 'Reward ledger should open for a chosen report.'); +assert.match(rewardLedger, /BATTLE LEDGER/, 'Reward detail should be framed as an on-demand ledger.'); +assert.match(rewardLedger, /\{ id: 'close', label: '장부 닫기', primary: true \}/, 'The ledger should have one clear primary close action.'); +assert.doesNotMatch(rewardLedger, /id: 'guided'/, 'The detail ledger should not compete with the camp next-action guide.'); +assert.doesNotMatch( + rewardLedger, + /playRewardRevealCue/, + 'Reopening an archived reward ledger should not replay the victory reward celebration.' +); + +const reportPanel = methodBlock(campSource, 'private renderReportPanel('); +assert.match(reportPanel, /showVictoryRewardAcknowledgement\(report\)/, 'Camp report should expose the detailed ledger on demand.'); +assert.doesNotMatch(reportPanel, /report\.rewardGold|report\.itemRewards/, 'Always-visible camp report should not repeat exact rewards.'); +assert.match(reportPanel, /세부 보상과 전과는 보상 장부에서 확인/, 'Always-visible camp report should explain where detail lives.'); + +const campReportSummary = methodBlock(campSource, 'private reportSummary('); +assert.doesNotMatch( + campReportSummary, + /rewardGold|campaign\?\.gold|군자금/, + 'Camp header should orient the player without repeating an exact reward or current gold total.' +); +assert.match(campReportSummary, /군영 준비 중/, 'Camp header should return attention to the next preparation phase.'); + +assert.match( + campSource, + /role: 'on-demand-ledger'/, + 'Debug state should expose the reward detail as an on-demand ledger.' +); +assert.match( + campSource, + /victorySettlementArrival:[\s\S]*blocking: false/, + 'Debug state should expose the camp arrival notice as non-blocking.' +); +assert.doesNotMatch( + campaignFlowSource, + /지난 보상:/, + 'Sortie preparation should describe the upcoming reward instead of repeating the previous battle reward.' +); + +console.log('Verified first-victory result, aftermath, camp, ledger, and sortie information hierarchy.'); + +function methodBlock(source, signature) { + const start = source.indexOf(signature); + assert.notEqual(start, -1, `Missing method signature: ${signature}`); + const nextPrivate = source.indexOf('\n private ', start + signature.length); + return source.slice(start, nextPrivate === -1 ? source.length : nextPrivate); +} diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 5d0aa45..714cf9c 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -1258,7 +1258,7 @@ try { await page.screenshot({ path: 'dist/verification-battle-result-victory.png', fullPage: true }); await clickBattleResultContinue(page); - await waitForCampAfterBattleResult(page, { preserveArrivalReward: true }); + await waitForCampAfterBattleResult(page, { preserveRewardLedger: true }); await page.screenshot({ path: 'dist/verification-camp.png', fullPage: true }); const campState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -1272,18 +1272,30 @@ try { ) { throw new Error(`Expected first camp to expose first-battle dialogue set: ${JSON.stringify(campState)}`); } - const arrivalSupplyAction = campState.victoryRewardAcknowledgement?.actions?.find((action) => action.id === 'supplies'); + + const firstCampReward = await revealCampVictoryRewardLedger( + page, + 'first-battle-zhuo-commandery' + ); + const arrivalSupplyAction = firstCampReward.actions?.find((action) => action.id === 'supplies'); if ( - campState.victoryRewardAcknowledgement?.visible !== true || !arrivalSupplyAction?.interactive || !arrivalSupplyAction.bounds ) { - throw new Error(`Expected first camp arrival reward modal with an interactive supplies deep link: ${JSON.stringify(campState.victoryRewardAcknowledgement)}`); + throw new Error(`Expected the first-camp reward ledger to expose an interactive supplies deep link: ${JSON.stringify(firstCampReward)}`); } await clickSceneBounds(page, 'CampScene', arrivalSupplyAction.bounds); await page.waitForFunction(() => { const camp = window.__HEROS_DEBUG__?.camp(); - return camp?.victoryRewardAcknowledgement?.visible === false && camp?.activeTab === 'supplies'; + const acknowledgedCategories = + camp?.campaign?.acknowledgedVictoryRewardCategories?.['first-battle-zhuo-commandery'] ?? []; + const suppliesTab = camp?.campTabs?.find((tab) => tab.id === 'supplies'); + return ( + camp?.victoryRewardAcknowledgement?.visible === false && + camp?.activeTab === 'supplies' && + acknowledgedCategories.includes('supplies') && + suppliesTab?.newBadgeVisible === false + ); }, undefined, { timeout: 30000 }); await page.evaluate(() => { @@ -10506,7 +10518,7 @@ try { throw new Error(`Expected the final battle result to show its aftermath before the reward camp: ${JSON.stringify(finalSettlement)}`); } await clickBattleBounds(page, finalSettlement.cta.bounds); - await waitForCampReady(page, { preserveArrivalReward: true }); + await waitForCampReady(page, { preserveRewardLedger: true }); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const camp = window.__HEROS_DEBUG__?.camp?.(); @@ -10516,18 +10528,24 @@ try { !activeScenes.includes('BattleScene') && camp?.campaign?.step === 'sixty-sixth-camp' && camp?.campBattleId === 'sixty-sixth-battle-wuzhang-final' && - reward?.visible === true && + camp?.victorySettlementArrival?.battleId === 'sixty-sixth-battle-wuzhang-final' && + camp.victorySettlementArrival.blocking === false && + reward?.role === 'on-demand-ledger' && + reward?.visible === false && reward?.battleId === 'sixty-sixth-battle-wuzhang-final' && - reward?.cards?.length === 6 && - reward.cards.every((card) => card.bounds && (card.actionId === null || card.interactive === true)) && - reward?.actions?.length === 4 && - reward.actions.every((action) => action.bounds && action.interactive === true) + reward?.toggleInteractive === true && + reward?.toggleButtonBounds && + reward?.cards?.length === 0 && + reward?.actions?.length === 0 ); }); - const finalCampReward = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement); + const finalCampReward = await revealCampVictoryRewardLedger( + page, + 'sixty-sixth-battle-wuzhang-final' + ); const closeRewardAction = finalCampReward?.actions?.find((action) => action.id === 'close'); if (!closeRewardAction?.bounds) { - throw new Error(`Expected an interactive close action on the final reward panel: ${JSON.stringify(finalCampReward)}`); + throw new Error(`Expected an interactive close action on the final reward ledger: ${JSON.stringify(finalCampReward)}`); } await page.screenshot({ path: 'dist/verification-final-camp-reward.png', fullPage: true }); await clickSceneBounds(page, 'CampScene', closeRewardAction.bounds); @@ -10831,20 +10849,87 @@ async function waitForCampReady(page, options = {}) { (!expected.step || campState?.campaign?.step === expected.step) ); }, options, { timeout: 90000 }); - if (!options.preserveArrivalReward) { - await dismissCampArrivalRewardIfVisible(page); + if (!options.preserveRewardLedger) { + await dismissCampVictoryRewardLedgerIfVisible(page); } await recordFlowRuntimeProgress(page, 'camp'); } -async function dismissCampArrivalRewardIfVisible(page) { +async function revealCampVictoryRewardLedger(page, expectedBattleId) { + const camp = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.() ?? null); + const arrival = camp?.victorySettlementArrival; + const ledger = camp?.victoryRewardAcknowledgement; + if ( + arrival?.battleId !== expectedBattleId || + arrival.blocking !== false || + !arrival.settledBattleIds?.includes(expectedBattleId) || + arrival.pendingCategories?.some((category) => category === 'gold' || category === 'reputation') || + !arrival.passiveCategoriesAcknowledged?.every( + (category) => category === 'gold' || category === 'reputation' + ) || + ledger?.role !== 'on-demand-ledger' || + ledger.available !== true || + ledger.visible !== false || + ledger.battleId !== expectedBattleId || + ledger.toggleInteractive !== true || + !ledger.toggleButtonBounds || + ledger.cards?.length !== 0 || + ledger.actions?.length !== 0 + ) { + throw new Error( + `Expected a nonblocking victory settlement with a closed on-demand reward ledger: ${JSON.stringify({ + expectedBattleId, + arrival, + ledger + })}` + ); + } + + await clickSceneBounds(page, 'CampScene', ledger.toggleButtonBounds); + await page.waitForFunction((battleId) => { + const reward = window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement; + const actionIds = reward?.actions?.map((action) => action.id).join(','); + const primaryActions = reward?.actions?.filter((action) => action.primary) ?? []; + return ( + reward?.role === 'on-demand-ledger' && + reward?.visible === true && + reward?.battleId === battleId && + reward?.cards?.length === 6 && + reward.cards.every((card) => card.bounds && (card.actionId === null || card.interactive === true)) && + actionIds === 'equipment,supplies,sortie,close' && + reward.actions.every((action) => action.bounds && action.interactive === true) && + primaryActions.length === 1 && + primaryActions[0]?.id === 'close' + ); + }, expectedBattleId, { timeout: 30000 }); + + const revealed = await page.evaluate(() => + window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement ?? null + ); + const pendingCategories = new Set(arrival.pendingCategories ?? []); + const newCategoryIds = new Set( + revealed?.cards?.filter((card) => card.new).map((card) => card.id) ?? [] + ); + if (![...pendingCategories].every((category) => newCategoryIds.has(category))) { + throw new Error( + `Expected pending destination categories to retain their NEW reward-card markers: ${JSON.stringify({ + pendingCategories: [...pendingCategories], + newCategoryIds: [...newCategoryIds], + revealed + })}` + ); + } + return revealed; +} + +async function dismissCampVictoryRewardLedgerIfVisible(page) { const acknowledgement = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement ?? null); if (!acknowledgement?.visible) { return; } const closeAction = acknowledgement.actions?.find((action) => action.id === 'close'); if (!closeAction?.interactive || !closeAction.bounds) { - throw new Error(`Expected an interactive close action on the camp arrival reward modal: ${JSON.stringify(acknowledgement)}`); + throw new Error(`Expected an interactive close action on the open camp reward ledger: ${JSON.stringify(acknowledgement)}`); } await clickSceneBounds(page, 'CampScene', closeAction.bounds); try { @@ -10862,7 +10947,7 @@ async function dismissCampArrivalRewardIfVisible(page) { try { await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === false, undefined, { timeout: 30000 }); } catch (error) { - throw new Error(`Camp arrival reward could not be dismissed by its actual close action or Escape fallback: ${JSON.stringify(afterPointer)}\n${error instanceof Error ? error.message : String(error)}`); + throw new Error(`Camp reward ledger could not be dismissed by its actual close action or Escape fallback: ${JSON.stringify(afterPointer)}\n${error instanceof Error ? error.message : String(error)}`); } } } @@ -11568,13 +11653,21 @@ async function restorePreviousFlowCheckpoint(page, segment) { await waitForCampReady(page, { step: expectedFlowCheckpointStep(previousSegment), withoutStory: true, - preserveArrivalReward: true + preserveRewardLedger: true }); await page.waitForFunction(() => { const acknowledgement = window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement; - return !acknowledgement?.battleId || acknowledgement.visible === true; + return ( + !acknowledgement?.battleId || + ( + acknowledgement.role === 'on-demand-ledger' && + acknowledgement.visible === false && + acknowledgement.toggleInteractive === true && + acknowledgement.toggleButtonBounds + ) + ); }, undefined, { timeout: 30000 }); - await dismissCampArrivalRewardIfVisible(page); + await dismissCampVictoryRewardLedgerIfVisible(page); const restoredCampaign = parseFlowCampaignStorage(await readAllFlowLocalStorage(page)); assertFlowCampaignPosition(restoredCampaign, previousSegment, 'restored runtime'); recordFlowProgress('checkpoint-restored', { diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 2e297ee..92c119e 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -2400,6 +2400,10 @@ try { }); assert(resultProbe.outcome === 'victory', `Expected forced victory result: ${JSON.stringify(resultProbe)}`); assert(resultProbe.resultTexts.includes('목표 정산'), `Expected result objective settlement title: ${JSON.stringify(resultProbe.resultTexts)}`); + assert( + ['소요 턴', '생존 아군', '격파 적군', '달성 목표'].every((label) => resultProbe.resultTexts.includes(label)), + `Expected the result summary to expose four exact debrief metrics ending with achieved objectives: ${JSON.stringify(resultProbe.resultTexts)}` + ); assert( ['전투 수입', '최고 전공', '획득 물자', '새 전력 · 해금'].every((label) => resultProbe.resultTexts.includes(label)), `Expected the result reward panel to expose four scannable reward cards: ${JSON.stringify(resultProbe.resultTexts)}` @@ -2428,6 +2432,12 @@ try { `Expected the completed victory result to expose one correctly named story CTA: ${JSON.stringify(firstResultState?.resultActions)}` ); const firstResultSave = await readCampaignSave(page); + const persistedFirstRewards = firstResultSave.current?.firstBattleReport?.campaignRewards; + assertBattleResultDebriefText( + resultProbe.resultTexts, + persistedFirstRewards, + 'first battle' + ); assert( firstResultSave.current?.pendingAftermathBattleId === 'first-battle-zhuo-commandery' && firstResultSave.slot1?.pendingAftermathBattleId === 'first-battle-zhuo-commandery', @@ -2687,8 +2697,26 @@ try { firstCampSaveAfterAftermath.slot1?.pendingAftermathBattleId === undefined, `Expected CampScene entry to complete the persisted aftermath exactly once: ${JSON.stringify(firstCampSaveAfterAftermath)}` ); - const firstRewardDisplaysByPage = Object.fromEntries( - firstCampTransition.rewardDisplays.map((display) => [display.pageId, display]) + const firstVictoryStoryPageIds = [ + 'first-victory-village', + 'first-victory-liu-bei', + 'first-victory-guan-yu', + 'first-victory-zhang-fei', + 'first-victory-next' + ]; + assert( + sameJsonValue( + firstCampTransition.rewardDisplays.map((display) => display.pageId), + firstVictoryStoryPageIds + ) && + firstCampTransition.rewardDisplays.every((display) => ( + display.source === 'campaign' && + display.battleId === 'first-battle-zhuo-commandery' && + display.rewardGold === null && + Array.isArray(display.items) && + display.items.length === 0 + )), + `Expected all five first-victory aftermath pages to retain campaign context without duplicating exact rewards: ${JSON.stringify(firstCampTransition.rewardDisplays)}` ); const firstNarrativeMemoriesByPage = Object.fromEntries( firstCampTransition.narrativeMemories.map((memory) => [memory.pageId, memory]) @@ -2714,49 +2742,9 @@ try { ), `Expected Guan Yu and Zhang Fei to remember the persisted first command in distinct voices: ${JSON.stringify(firstCampTransition.narrativeMemories)}` ); - const persistedFirstRewards = firstResultSave.current?.firstBattleReport?.campaignRewards; - const expectedFirstStorySupplies = [ - ...(persistedFirstRewards?.supplies ?? []), - ...firstSortieOrderBonus.rewardItems - ]; - assert( - firstCampTransition.rewardDisplays.length === 5 && - firstCampTransition.rewardDisplays.every((display) => ( - display.source === 'campaign' && display.battleId === 'first-battle-zhuo-commandery' - )) && - firstRewardDisplaysByPage['first-victory-village']?.rewardGold === expectedFirstDisplayedRewardGold && - sameJsonValue( - firstRewardDisplaysByPage['first-victory-village']?.items.filter((item) => item.category === 'supply').map((item) => item.label), - expectedFirstStorySupplies - ) && - sameJsonValue( - firstRewardDisplaysByPage['first-victory-liu-bei']?.items.filter((item) => item.category === 'equipment').map((item) => item.label), - persistedFirstRewards?.equipment - ) && - sameJsonValue( - firstRewardDisplaysByPage['first-victory-liu-bei']?.items.filter((item) => item.category === 'reputation').map((item) => item.label), - persistedFirstRewards?.reputation - ) && - sameJsonValue( - [ - ...(firstRewardDisplaysByPage['first-victory-guan-yu']?.items ?? []), - ...(firstRewardDisplaysByPage['first-victory-zhang-fei']?.items ?? []) - ].filter((item) => item.category === 'recruit').map((item) => item.id.replace('recruit-', '')), - persistedFirstRewards?.recruits.map((recruit) => recruit.unitId) - ) && - sameJsonValue( - firstRewardDisplaysByPage['first-victory-next']?.items.filter((item) => item.category === 'unlock').map((item) => item.id.replace('unlock-', '')), - persistedFirstRewards?.unlocks.map((unlock) => unlock.battleId) - ), - `Expected every first-victory story reward card to match the persisted battle report: ${JSON.stringify({ displays: firstCampTransition.rewardDisplays, persistedFirstRewards })}` - ); await waitForCampSkinTransition(page, 'yellow-turban'); await waitForCampSoundscapeTransition(page, 'camp-rally', 'campfire-ambience'); - await page.waitForFunction( - () => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === true, - undefined, - { timeout: 30000 } - ); + await waitForCampVictoryRewardLedgerReady(page, 'first-battle-zhuo-commandery'); await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp'); @@ -2773,32 +2761,52 @@ try { } }); assert(firstCampProbe.state?.campaign?.step === 'first-camp', `Expected victory to return to first camp: ${JSON.stringify(firstCampProbe.state)}`); - const firstArrivalReward = firstCampProbe.state?.victoryRewardAcknowledgement; + const firstArrival = firstCampProbe.state?.victorySettlementArrival; + const firstArrivalLedger = firstCampProbe.state?.victoryRewardAcknowledgement; + const firstArrivalSaveBeforeLedger = await readCampaignSave(page); + const firstArrivalAcknowledgements = campaignVictoryAcknowledgementSnapshot( + firstArrivalSaveBeforeLedger, + 'first-battle-zhuo-commandery' + ); + const firstArrivalNoticeDismissals = campaignVictoryNoticeDismissalSnapshot( + firstArrivalSaveBeforeLedger, + 'first-battle-zhuo-commandery' + ); assert( - firstArrivalReward?.visible === true && - firstArrivalReward.battleId === 'first-battle-zhuo-commandery' && - firstArrivalReward.cards.length === 6 && - firstArrivalReward.cards.find((card) => card.id === 'gold')?.items.includes(`+${expectedFirstDisplayedRewardGold} 군자금`) && - firstArrivalReward.cards.find((card) => card.id === 'supplies')?.items.includes('상처약 1') && - firstArrivalReward.cards.every((card) => card.iconReady === true) && - firstArrivalReward.cards.find((card) => card.id === 'equipment')?.iconKind === 'item' && - firstArrivalReward.cards.find((card) => card.id === 'equipment')?.new === true && - firstArrivalReward.cards.find((card) => card.id === 'equipment')?.actionId === 'equipment' && - firstArrivalReward.cards.find((card) => card.id === 'equipment')?.interactive === true && - firstArrivalReward.cards.find((card) => card.id === 'recruits')?.iconKind === 'portrait' && - firstArrivalReward.cards.find((card) => card.id === 'recruits')?.new === true && - firstArrivalReward.cards.find((card) => card.id === 'recruits')?.actionId === 'sortie' && - firstArrivalReward.cards.find((card) => card.id === 'unlocks')?.new === true && - firstArrivalReward.cards.find((card) => card.id === 'unlocks')?.actionId === 'sortie' && - firstArrivalReward.cards.every((card) => boundsWithinFhdViewport(card.bounds)) && - firstArrivalReward.actions.map((action) => action.id).join(',') === 'equipment,supplies,guided,sortie,close' && - firstArrivalReward.actions.filter((action) => action.primary).length === 1 && - firstArrivalReward.actions.find((action) => action.id === 'guided')?.label === '북문 정찰막 둘러보기' && - firstArrivalReward.actions.find((action) => action.id === 'guided')?.primary === true && - firstArrivalReward.actions.find((action) => action.id === 'sortie')?.label === '바로 출진' && - firstArrivalReward.actions.find((action) => action.id === 'sortie')?.primary === false && - firstArrivalReward.actions.every((action) => action.interactive && boundsWithinFhdViewport(action.bounds)) && - firstCampProbe.state?.sortieCommand?.label === '정찰막 둘러보기' && + firstArrival?.battleId === 'first-battle-zhuo-commandery' && + firstArrival.battleTitle === persistedFirstReport?.battleTitle && + typeof firstArrival.message === 'string' && + firstArrival.message.includes(firstArrival.battleTitle) && + firstArrival.message.includes('보상 장부') && + sameJsonValue(firstArrival.settledBattleIds, ['first-battle-zhuo-commandery']) && + sameJsonValue(firstArrival.pendingCategories, ['equipment', 'supplies', 'recruits', 'unlocks']) && + sameJsonValue(firstArrival.passiveCategoriesAcknowledged, ['gold', 'reputation']) && + firstArrival.blocking === false && + firstArrivalNoticeDismissals.current === true && + firstArrivalNoticeDismissals.slot1 === true && + ['current', 'slot1'].every((saveKey) => ( + firstArrivalAcknowledgements[saveKey].battleAcknowledged === false && + sameJsonValue(firstArrivalAcknowledgements[saveKey].categories, ['gold', 'reputation']) + )), + `Expected first-camp arrival to settle the notice nonblockingly, acknowledge only passive rewards, and leave destination rewards NEW: ${JSON.stringify({ + arrival: firstArrival, + acknowledgements: firstArrivalAcknowledgements, + noticeDismissals: firstArrivalNoticeDismissals + })}` + ); + assert( + firstArrivalLedger?.role === 'on-demand-ledger' && + firstArrivalLedger.available === true && + firstArrivalLedger.visible === false && + firstArrivalLedger.battleId === 'first-battle-zhuo-commandery' && + boundsWithinFhdViewport(firstArrivalLedger.toggleButtonBounds) && + firstArrivalLedger.toggleInteractive === true && + firstArrivalLedger.cards.length === 0 && + firstArrivalLedger.actions.length === 0, + `Expected first-camp rewards to remain closed behind one FHD-safe on-demand ledger button: ${JSON.stringify(firstArrivalLedger)}` + ); + assert( + firstCampProbe.state?.sortieCommand?.label === '정찰막 둘러보기' && firstCampProbe.state?.sortieCommand?.guidedKind === 'visit' && firstCampProbe.state?.sortieCommand?.targetId === firstPursuitScoutVisitId && firstCampProbe.state?.sortieCommand?.interactive === true && @@ -2819,8 +2827,7 @@ try { firstCampProbe.state?.campTabs?.find((tab) => tab.id === 'visit')?.visualState === 'active' && firstCampProbe.state?.campTabs?.find((tab) => tab.id === 'visit')?.newBadgeVisible === true && firstCampProbe.state?.campTabs?.filter((tab) => ['supplies', 'equipment'].includes(tab.id)).every((tab) => tab.newBadgeVisible === true), - `Expected first camp arrival to expose bounded reward cards, deep links, and NEW badges: ${JSON.stringify({ - reward: firstArrivalReward, + `Expected first camp arrival to preserve its guided next action, recruit history, and destination NEW badges without a blocking modal: ${JSON.stringify({ sortieCommand: firstCampProbe.state?.sortieCommand, activeTab: firstCampProbe.state?.activeTab, selectedVisitId: firstCampProbe.state?.selectedVisitId, @@ -2828,6 +2835,79 @@ try { sortieRoster: firstCampProbe.state?.sortieRoster })}` ); + const firstArrivalLedgerTogglePoint = await readCampVictoryRewardLedgerTogglePoint(page); + await page.mouse.click(firstArrivalLedgerTogglePoint.x, firstArrivalLedgerTogglePoint.y); + await page.waitForFunction(() => { + const ledger = window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement; + return ledger?.visible === true && ledger.cards?.length === 6 && ledger.actions?.length === 4; + }, undefined, { timeout: 30000 }); + const firstArrivalReward = await page.evaluate( + () => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement + ); + const firstArrivalCardsById = Object.fromEntries( + firstArrivalReward.cards.map((card) => [card.id, card]) + ); + const expectedFirstLedgerSupplies = [ + ...(persistedFirstRewards?.supplies ?? []), + ...firstSortieOrderBonus.rewardItems + ]; + assert( + firstArrivalReward.role === 'on-demand-ledger' && + firstArrivalReward.available === true && + firstArrivalReward.visible === true && + firstArrivalReward.battleId === 'first-battle-zhuo-commandery' && + sameJsonValue( + firstArrivalReward.cards.map((card) => card.id), + ['gold', 'equipment', 'supplies', 'reputation', 'recruits', 'unlocks'] + ) && + firstArrivalCardsById.gold?.items[0] === `+${expectedFirstDisplayedRewardGold} 군자금` && + ( + firstSortieOrderBonus.rewardGold === 0 || + firstArrivalCardsById.gold?.items[1]?.includes(`+${firstSortieOrderBonus.rewardGold}`) + ) && + sameJsonValue(firstArrivalCardsById.equipment?.items, persistedFirstRewards?.equipment ?? []) && + sameJsonValue(firstArrivalCardsById.supplies?.items, expectedFirstLedgerSupplies) && + sameJsonValue(firstArrivalCardsById.reputation?.items, persistedFirstRewards?.reputation ?? []) && + sameJsonValue( + firstArrivalCardsById.recruits?.items, + (persistedFirstRewards?.recruits ?? []).map((recruit) => recruit.name) + ) && + sameJsonValue( + firstArrivalCardsById.unlocks?.items, + (persistedFirstRewards?.unlocks ?? []).map((unlock) => unlock.title) + ) && + sameJsonValue( + firstArrivalReward.cards.map((card) => card.iconKind), + ['mark', 'item', 'battle-ui', 'mark', 'portrait', 'battle-ui'] + ) && + sameJsonValue( + firstArrivalReward.cards.filter((card) => card.new).map((card) => card.id), + ['equipment', 'supplies', 'recruits', 'unlocks'] + ) && + sameJsonValue( + firstArrivalReward.cards.map((card) => ({ id: card.id, actionId: card.actionId })), + [ + { id: 'gold', actionId: null }, + { id: 'equipment', actionId: 'equipment' }, + { id: 'supplies', actionId: 'supplies' }, + { id: 'reputation', actionId: null }, + { id: 'recruits', actionId: 'sortie' }, + { id: 'unlocks', actionId: 'sortie' } + ] + ) && + firstArrivalReward.cards.every((card) => ( + card.iconReady === true && + card.interactive === ['equipment', 'supplies', 'recruits', 'unlocks'].includes(card.id) && + boundsWithinFhdViewport(card.bounds) + )) && + sameJsonValue( + firstArrivalReward.actions.map((action) => action.id), + ['equipment', 'supplies', 'sortie', 'close'] + ) && + firstArrivalReward.actions.filter((action) => action.primary).map((action) => action.id).join(',') === 'close' && + firstArrivalReward.actions.every((action) => action.interactive && boundsWithinFhdViewport(action.bounds)), + `Expected the requested first-camp ledger to expose six exact reward cards and four actions with close as the sole primary: ${JSON.stringify(firstArrivalReward)}` + ); const firstArrivalEquipmentPoint = await readCampVictoryRewardCardControlPoint(page, 'equipment'); await page.mouse.click(firstArrivalEquipmentPoint.x, firstArrivalEquipmentPoint.y); await page.waitForFunction(() => { @@ -2836,23 +2916,67 @@ try { }, undefined, { timeout: 30000 }); const acknowledgedFirstArrival = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const acknowledgedFirstArrivalSave = await readCampaignSave(page); + const acknowledgedFirstArrivalCategories = campaignVictoryAcknowledgementSnapshot( + acknowledgedFirstArrivalSave, + 'first-battle-zhuo-commandery' + ); assert( - !acknowledgedFirstArrivalSave.current?.acknowledgedVictoryRewardBattleIds?.includes('first-battle-zhuo-commandery') && - !acknowledgedFirstArrivalSave.slot1?.acknowledgedVictoryRewardBattleIds?.includes('first-battle-zhuo-commandery') && - acknowledgedFirstArrivalSave.current?.acknowledgedVictoryRewardCategories?.['first-battle-zhuo-commandery']?.includes('equipment') && - acknowledgedFirstArrivalSave.slot1?.acknowledgedVictoryRewardCategories?.['first-battle-zhuo-commandery']?.includes('equipment') && + sameJsonValue( + campaignSaveWithoutVictoryRewardAcknowledgementEffects(acknowledgedFirstArrivalSave), + campaignSaveWithoutVictoryRewardAcknowledgementEffects(firstArrivalSaveBeforeLedger) + ) && + ['current', 'slot1'].every((saveKey) => ( + acknowledgedFirstArrivalCategories[saveKey].battleAcknowledged === false && + sameJsonValue( + acknowledgedFirstArrivalCategories[saveKey].categories, + ['gold', 'equipment', 'reputation'] + ) + )) && + campaignVictoryNoticeDismissalSnapshot( + acknowledgedFirstArrivalSave, + 'first-battle-zhuo-commandery' + ).current === true && + campaignVictoryNoticeDismissalSnapshot( + acknowledgedFirstArrivalSave, + 'first-battle-zhuo-commandery' + ).slot1 === true && acknowledgedFirstArrival.campTabs.find((tab) => tab.id === 'equipment')?.newBadgeVisible === false && acknowledgedFirstArrival.campTabs.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true, - `Expected an equipment deep link to acknowledge only equipment while preserving other reward NEW states: ${JSON.stringify({ acknowledgedFirstArrival, acknowledgedFirstArrivalSave })}` + `Expected an equipment ledger deep link to add only equipment acknowledgement while preserving passive settlement, other NEW states, and all unrelated save data: ${JSON.stringify({ + before: firstArrivalAcknowledgements, + after: acknowledgedFirstArrivalCategories, + state: acknowledgedFirstArrival + })}` ); await clickLegacyUi(page, 576, 38); await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.activeTab === 'status', undefined, { timeout: 30000 }); firstCampProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const reportBounds = scene?.reportPanelBackground?.getBounds?.(); + const texts = textValues(scene?.contentObjects); return { state: window.__HEROS_DEBUG__?.camp(), summary: scene?.reportSummary?.(), - texts: textValues(scene?.contentObjects) + texts, + reportTexts: reportBounds + ? (scene?.contentObjects ?? []) + .filter((object) => object?.type === 'Text') + .filter((object) => { + const bounds = object.getBounds?.(); + if (!bounds) { + return false; + } + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return ( + centerX >= reportBounds.x && + centerX <= reportBounds.x + reportBounds.width && + centerY >= reportBounds.y && + centerY <= reportBounds.y + reportBounds.height + ); + }) + .map((object) => object.text) + : [] }; function textValues(objects = []) { @@ -2909,17 +3033,33 @@ try { `Expected first camp deployment preview to avoid duplicate tiles: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}` ); assert( - firstCampProbe.summary?.includes('군자금') && !firstCampProbe.summary.includes('\n'), - `Expected the compact camp summary to show held gold on one line: ${JSON.stringify(firstCampProbe)}` + firstCampProbe.summary?.includes('군영 준비 중') && + !firstCampProbe.summary.includes('군자금') && + !firstCampProbe.summary.includes('\n'), + `Expected the compact camp summary to show readiness on one line without repeating an exact reward amount: ${JSON.stringify(firstCampProbe)}` ); - assert(firstCampProbe.texts.some((text) => text.includes('전투 보상')), `Expected camp report to show battle reward: ${JSON.stringify(firstCampProbe.texts)}`); + const firstExactRewardLabels = [ + `+${expectedFirstDisplayedRewardGold} 군자금`, + ...(persistedFirstRewards?.supplies ?? []), + ...firstSortieOrderBonus.rewardItems, + ...(persistedFirstRewards?.equipment ?? []), + ...(persistedFirstRewards?.reputation ?? []), + ...(persistedFirstRewards?.recruits ?? []).map((recruit) => recruit.name), + ...(persistedFirstRewards?.unlocks ?? []).map((unlock) => unlock.title) + ]; assert( - firstCampProbe.texts.some((text) => text.includes('명령 기록') && text.includes('재정비')), - `Expected the camp report to show the persisted command record: ${JSON.stringify(firstCampProbe.texts)}` + firstCampProbe.reportTexts.some((text) => text.includes('세부 보상과 전과는 보상 장부에서 확인')) && + firstExactRewardLabels.every( + (rewardLabel) => !firstCampProbe.reportTexts.some((text) => text.includes(rewardLabel)) + ), + `Expected the always-on camp report to route exact rewards to the ledger without repeating names, titles, items, or amounts: ${JSON.stringify({ + reportTexts: firstCampProbe.reportTexts, + hiddenRewardLabels: firstExactRewardLabels + })}` ); assert( - firstCampProbe.texts.some((text) => text.includes(`다음 ${firstBattleUnlockLabel}`)), - `Expected camp report to surface first battle unlock label: ${JSON.stringify(firstCampProbe.texts)}` + firstCampProbe.reportTexts.some((text) => text.includes('명령 기록') && text.includes('재정비')), + `Expected the camp report to show the persisted command record: ${JSON.stringify(firstCampProbe.reportTexts)}` ); assert( firstCampProbe.state?.report?.campaignRewards?.unlocks?.some( @@ -3079,15 +3219,22 @@ try { undefined, { timeout: 90000 } ); - await page.waitForTimeout(250); - if (await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement?.visible === true)) { - await page.keyboard.press('Escape'); - await page.waitForFunction( - () => window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement?.visible === false, - undefined, - { timeout: 30000 } + await page.waitForFunction(() => { + const ledger = window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement; + return ( + ledger?.role === 'on-demand-ledger' && + ledger.available === true && + ledger.visible === false && + ledger.toggleInteractive === true ); - } + }, undefined, { timeout: 30000 }); + const returnedFirstCampLedger = await page.evaluate( + () => window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement + ); + assert( + boundsWithinFhdViewport(returnedFirstCampLedger?.toggleButtonBounds), + `Expected returning from camp exploration to keep the reward ledger closed and available on demand: ${JSON.stringify(returnedFirstCampLedger)}` + ); const returnedFirstCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()); if ( returnedFirstCampState?.activeTab !== 'visit' || @@ -7356,8 +7503,22 @@ try { await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await waitForBattleOutcome(page, 'victory'); - const midResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const midResultProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + return { + state: window.__HEROS_DEBUG__?.battle(), + texts: (scene?.resultObjects ?? []) + .filter((object) => object?.type === 'Text') + .map((object) => object.text) + }; + }); + const midResultState = midResultProbe.state; const midResultSaveBeforeUpdate = await readCampaignSave(page); + assertBattleResultDebriefText( + midResultProbe.texts, + midResultSaveBeforeUpdate.current?.firstBattleReport?.campaignRewards, + 'mid-campaign battle' + ); const midResultPerformance = midResultState?.sortieEvaluation?.snapshot; const midResultSortieReviewBeforePresetUpdate = structuredClone( midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortieReview @@ -7581,16 +7742,16 @@ try { midImprovementAftermath.rewardDisplays.length === 0, `Expected next-sortie improvement to show only the completed battle's aftermath before camp: ${JSON.stringify(midImprovementAftermath)}` ); - await page.waitForFunction( - () => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === true, - undefined, - { timeout: 30000 } - ); + await waitForCampVictoryRewardLedgerReady(page, midCampNextBattleId); const pendingMidImprovementReward = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const pendingMidImprovementSave = await readCampaignSave(page); const midImprovementCampEntrySaveUnchanged = sameJsonValue( - campaignSaveWithoutCampRecruitmentEffects(pendingMidImprovementSave), - campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave) + campaignSaveWithoutVictoryRewardAcknowledgementEffects( + campaignSaveWithoutCampRecruitmentEffects(pendingMidImprovementSave) + ), + campaignSaveWithoutVictoryRewardAcknowledgementEffects( + campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave) + ) ); const midImprovementAftermathCompleted = updatedMobileResultSave.current?.pendingAftermathBattleId === midCampNextBattleId && @@ -7601,29 +7762,91 @@ try { pendingMidImprovementSave, midCampNextBattleId ); + const midImprovementNoticeDismissals = campaignVictoryNoticeDismissalSnapshot( + pendingMidImprovementSave, + midCampNextBattleId + ); + const midImprovementArrival = pendingMidImprovementReward?.victorySettlementArrival; + const midImprovementLedger = pendingMidImprovementReward?.victoryRewardAcknowledgement; const midImprovementRewardDestinationPending = Object.values(midImprovementRewardAcknowledgements).every( - (entry) => !entry.battleAcknowledged && - !entry.categories.includes('equipment') && - !entry.categories.includes('supplies') + (entry) => ( + !entry.battleAcknowledged && + sameJsonValue(entry.categories, midImprovementArrival?.passiveCategoriesAcknowledged ?? []) && + (midImprovementArrival?.pendingCategories ?? []).every( + (category) => !entry.categories.includes(category) + ) + ) ); assert( pendingMidImprovementReward?.openSortiePrepOnCreate === true && pendingMidImprovementReward.sortieVisible === false && - pendingMidImprovementReward.victoryRewardAcknowledgement?.battleId === midCampNextBattleId && + midImprovementArrival?.battleId === midCampNextBattleId && + midImprovementArrival.battleTitle === pendingMidImprovementReward.report?.battleTitle && + typeof midImprovementArrival.message === 'string' && + midImprovementArrival.message.includes('보상 장부') && + sameJsonValue(midImprovementArrival.settledBattleIds, [midCampNextBattleId]) && + midImprovementArrival.passiveCategoriesAcknowledged.every( + (category) => ['gold', 'reputation'].includes(category) + ) && + midImprovementArrival.pendingCategories.length > 0 && + midImprovementArrival.pendingCategories.every( + (category) => !['gold', 'reputation'].includes(category) + ) && + midImprovementArrival.blocking === false && + midImprovementNoticeDismissals.current === true && + midImprovementNoticeDismissals.slot1 === true && + midImprovementLedger?.role === 'on-demand-ledger' && + midImprovementLedger.available === true && + midImprovementLedger.visible === false && + midImprovementLedger.battleId === midCampNextBattleId && + boundsWithinFhdViewport(midImprovementLedger.toggleButtonBounds) && + midImprovementLedger.toggleInteractive === true && + midImprovementLedger.cards.length === 0 && + midImprovementLedger.actions.length === 0 && midImprovementCampEntrySaveUnchanged && midImprovementAftermathCompleted && midImprovementRewardDestinationPending, - `Expected next-sortie improvement to preserve its requested prep while showing the unacknowledged victory reward first: ${JSON.stringify({ + `Expected next-sortie improvement arrival to remain nonblocking with passive settlement and an unopened on-demand ledger: ${JSON.stringify({ openSortiePrepOnCreate: pendingMidImprovementReward?.openSortiePrepOnCreate, sortieVisible: pendingMidImprovementReward?.sortieVisible, - rewardBattleId: pendingMidImprovementReward?.victoryRewardAcknowledgement?.battleId, + arrival: midImprovementArrival, + ledger: midImprovementLedger, expectedBattleId: midCampNextBattleId, saveUnchanged: midImprovementCampEntrySaveUnchanged, aftermathCompleted: midImprovementAftermathCompleted, rewardDestinationPending: midImprovementRewardDestinationPending, - rewardAcknowledgements: midImprovementRewardAcknowledgements + rewardAcknowledgements: midImprovementRewardAcknowledgements, + noticeDismissals: midImprovementNoticeDismissals })}` ); + const midImprovementLedgerTogglePoint = await readCampVictoryRewardLedgerTogglePoint(page); + await page.mouse.click(midImprovementLedgerTogglePoint.x, midImprovementLedgerTogglePoint.y); + await page.waitForFunction(() => { + const ledger = window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement; + return ledger?.visible === true && ledger.cards?.length === 6 && ledger.actions?.length === 4; + }, undefined, { timeout: 30000 }); + const openedMidImprovementLedger = await page.evaluate( + () => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement + ); + const openedMidImprovementLedgerSave = await readCampaignSave(page); + assert( + openedMidImprovementLedger?.role === 'on-demand-ledger' && + openedMidImprovementLedger.available === true && + openedMidImprovementLedger.battleId === midCampNextBattleId && + sameJsonValue( + openedMidImprovementLedger.cards.map((card) => card.id), + ['gold', 'equipment', 'supplies', 'reputation', 'recruits', 'unlocks'] + ) && + sameJsonValue( + openedMidImprovementLedger.actions.map((action) => action.id), + ['equipment', 'supplies', 'sortie', 'close'] + ) && + openedMidImprovementLedger.actions.filter((action) => action.primary).map((action) => action.id).join(',') === 'close' && + openedMidImprovementLedger.cards.every((card) => boundsWithinFhdViewport(card.bounds)) && + openedMidImprovementLedger.actions.every((action) => action.interactive && boundsWithinFhdViewport(action.bounds)) && + sameJsonValue(openedMidImprovementLedgerSave, pendingMidImprovementSave), + `Expected opening the mid-campaign ledger to reveal the six-card/four-action contract without mutating either save: ${JSON.stringify(openedMidImprovementLedger)}` + ); await page.screenshot({ path: `${screenshotDir}/rc-mid-improvement-victory-reward.png`, fullPage: true }); const midImprovementSortiePoint = await readCampVictoryRewardControlPoint(page, 'sortie'); await page.mouse.click(midImprovementSortiePoint.x, midImprovementSortiePoint.y); @@ -7909,8 +8132,9 @@ try { { timeout: 30000 } ); + const finalBattleId = 'sixty-sixth-battle-wuzhang-final'; await seedCampaignSave(page, { - battleId: 'sixty-sixth-battle-wuzhang-final', + battleId: finalBattleId, battleTitle: '오장원 최종전', step: 'sixty-sixth-camp', gold: 9800, @@ -7925,12 +8149,17 @@ try { await waitForCamp(page); await waitForCampSkinTransition(page, 'northern'); await waitForCampSoundscapeTransition(page, 'camp-frontier', 'mountain-wind-ambience'); - await page.waitForFunction( - () => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === true, - undefined, - { timeout: 30000 } - ); + await waitForCampVictoryRewardLedgerReady(page, finalBattleId); const finalCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const finalCampArrivalSave = await readCampaignSave(page); + const finalCampArrivalAcknowledgements = campaignVictoryAcknowledgementSnapshot( + finalCampArrivalSave, + finalBattleId + ); + const finalCampArrivalNoticeDismissals = campaignVictoryNoticeDismissalSnapshot( + finalCampArrivalSave, + finalBattleId + ); assert(finalCampState?.campaign?.step === 'sixty-sixth-camp', `Expected final camp save to continue: ${JSON.stringify(finalCampState)}`); assertCampSkinState(finalCampState, 'northern', 'final camp'); assertCampSoundscapeState(finalCampState, { @@ -7954,11 +8183,71 @@ try { `Expected final camp deployment preview to stay empty: ${JSON.stringify(finalCampState?.sortieDeploymentPreview)}` ); assert( - finalCampState.victoryRewardAcknowledgement?.visible === true && - finalCampState.victoryRewardAcknowledgement?.battleId === 'sixty-sixth-battle-wuzhang-final', - `Expected the seeded final victory to expose its undismissed reward notice: ${JSON.stringify(finalCampState.victoryRewardAcknowledgement)}` + finalCampState.victorySettlementArrival?.battleId === finalBattleId && + finalCampState.victorySettlementArrival.battleTitle === '오장원 최종전' && + finalCampState.victorySettlementArrival.message.includes('보상 장부') && + sameJsonValue(finalCampState.victorySettlementArrival.settledBattleIds, [finalBattleId]) && + sameJsonValue( + finalCampState.victorySettlementArrival.pendingCategories, + ['equipment', 'supplies'] + ) && + sameJsonValue( + finalCampState.victorySettlementArrival.passiveCategoriesAcknowledged, + ['gold', 'reputation'] + ) && + finalCampState.victorySettlementArrival.blocking === false && + finalCampArrivalNoticeDismissals.current === true && + finalCampArrivalNoticeDismissals.slot1 === true && + ['current', 'slot1'].every((saveKey) => ( + finalCampArrivalAcknowledgements[saveKey].battleAcknowledged === false && + sameJsonValue(finalCampArrivalAcknowledgements[saveKey].categories, ['gold', 'reputation']) + )) && + finalCampState.victoryRewardAcknowledgement?.role === 'on-demand-ledger' && + finalCampState.victoryRewardAcknowledgement.available === true && + finalCampState.victoryRewardAcknowledgement.visible === false && + finalCampState.victoryRewardAcknowledgement.battleId === finalBattleId && + boundsWithinFhdViewport(finalCampState.victoryRewardAcknowledgement.toggleButtonBounds) && + finalCampState.victoryRewardAcknowledgement.toggleInteractive === true && + finalCampState.victoryRewardAcknowledgement.cards.length === 0 && + finalCampState.victoryRewardAcknowledgement.actions.length === 0 && + finalCampState.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true && + finalCampState.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true, + `Expected seeded final victory arrival to settle passives nonblockingly and leave its destination rewards behind an unopened ledger: ${JSON.stringify({ + arrival: finalCampState.victorySettlementArrival, + ledger: finalCampState.victoryRewardAcknowledgement, + acknowledgements: finalCampArrivalAcknowledgements, + noticeDismissals: finalCampArrivalNoticeDismissals, + tabs: finalCampState.campTabs + })}` + ); + const finalCampLedgerTogglePoint = await readCampVictoryRewardLedgerTogglePoint(page); + await page.mouse.click(finalCampLedgerTogglePoint.x, finalCampLedgerTogglePoint.y); + await page.waitForFunction(() => { + const ledger = window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement; + return ledger?.visible === true && ledger.cards?.length === 6 && ledger.actions?.length === 4; + }, undefined, { timeout: 30000 }); + const finalCampOpenedLedger = await page.evaluate( + () => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement ); const finalCampRewardSaveBeforeClose = await readCampaignSave(page); + assert( + finalCampOpenedLedger?.role === 'on-demand-ledger' && + finalCampOpenedLedger.available === true && + finalCampOpenedLedger.battleId === finalBattleId && + sameJsonValue( + finalCampOpenedLedger.cards.map((card) => card.id), + ['gold', 'equipment', 'supplies', 'reputation', 'recruits', 'unlocks'] + ) && + sameJsonValue( + finalCampOpenedLedger.actions.map((action) => action.id), + ['equipment', 'supplies', 'sortie', 'close'] + ) && + finalCampOpenedLedger.actions.filter((action) => action.primary).map((action) => action.id).join(',') === 'close' && + finalCampOpenedLedger.cards.every((card) => boundsWithinFhdViewport(card.bounds)) && + finalCampOpenedLedger.actions.every((action) => action.interactive && boundsWithinFhdViewport(action.bounds)) && + sameJsonValue(finalCampRewardSaveBeforeClose, finalCampArrivalSave), + `Expected the requested final-camp ledger to open with six cards and four actions without changing settlement state: ${JSON.stringify(finalCampOpenedLedger)}` + ); const finalCampRewardClosePoint = await readCampVictoryRewardControlPoint(page, 'close'); await page.mouse.click(finalCampRewardClosePoint.x, finalCampRewardClosePoint.y); await page.waitForFunction( @@ -7968,23 +8257,24 @@ try { ); const finalCampAfterRewardClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const finalCampRewardSaveAfterClose = await readCampaignSave(page); - const finalBattleId = 'sixty-sixth-battle-wuzhang-final'; assert( - sameJsonValue( - campaignSaveWithoutVictoryRewardNoticeDismissals(finalCampRewardSaveAfterClose), - campaignSaveWithoutVictoryRewardNoticeDismissals(finalCampRewardSaveBeforeClose) - ) && + sameJsonValue(finalCampRewardSaveAfterClose, finalCampRewardSaveBeforeClose) && sameJsonValue( campaignVictoryAcknowledgementSnapshot(finalCampRewardSaveAfterClose, finalBattleId), campaignVictoryAcknowledgementSnapshot(finalCampRewardSaveBeforeClose, finalBattleId) ) && - campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId).current === false && - campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId).slot1 === false && + campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId).current === true && + campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId).slot1 === true && campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveAfterClose, finalBattleId).current === true && campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveAfterClose, finalBattleId).slot1 === true && + finalCampAfterRewardClose?.victorySettlementArrival?.battleId === finalBattleId && + finalCampAfterRewardClose?.victorySettlementArrival?.blocking === false && + finalCampAfterRewardClose?.victoryRewardAcknowledgement?.role === 'on-demand-ledger' && + finalCampAfterRewardClose?.victoryRewardAcknowledgement?.available === true && + finalCampAfterRewardClose?.victoryRewardAcknowledgement?.visible === false && finalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true && finalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true, - `Expected closing the final-camp reward notice to persist only its dismissal while preserving destination-specific NEW states: ${JSON.stringify({ + `Expected closing an on-demand final-camp ledger to leave its already settled save and destination-specific NEW states completely unchanged: ${JSON.stringify({ beforeDismissal: campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId), afterDismissal: campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveAfterClose, finalBattleId), beforeAcknowledgement: campaignVictoryAcknowledgementSnapshot(finalCampRewardSaveBeforeClose, finalBattleId), @@ -8003,10 +8293,18 @@ try { const resumedFinalCampAfterRewardClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( resumedFinalCampAfterRewardClose?.campaign?.step === 'sixty-sixth-camp' && + resumedFinalCampAfterRewardClose?.victorySettlementArrival === null && + resumedFinalCampAfterRewardClose?.victoryRewardAcknowledgement?.role === 'on-demand-ledger' && + resumedFinalCampAfterRewardClose?.victoryRewardAcknowledgement?.available === true && resumedFinalCampAfterRewardClose?.victoryRewardAcknowledgement?.visible === false && + resumedFinalCampAfterRewardClose?.victoryRewardAcknowledgement?.battleId === finalBattleId && + boundsWithinFhdViewport( + resumedFinalCampAfterRewardClose?.victoryRewardAcknowledgement?.toggleButtonBounds + ) && + resumedFinalCampAfterRewardClose?.victoryRewardAcknowledgement?.toggleInteractive === true && resumedFinalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true && resumedFinalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true, - `Expected title continue to keep the dismissed reward notice closed without consuming NEW states: ${JSON.stringify(resumedFinalCampAfterRewardClose)}` + `Expected title continue not to replay the settled arrival while keeping the on-demand ledger and destination NEW states available: ${JSON.stringify(resumedFinalCampAfterRewardClose)}` ); await captureStableCampScreenshot(page, `${screenshotDir}/rc-final-camp.png`, 0, 'final camp'); await assertFinalCampRosterPagination(page); @@ -10169,6 +10467,35 @@ async function waitForCamp(page) { })); } +async function waitForCampVictoryRewardLedgerReady(page, battleId) { + await page.waitForFunction((expectedBattleId) => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + const camp = window.__HEROS_DEBUG__?.camp?.(); + const arrival = camp?.victorySettlementArrival; + const ledger = camp?.victoryRewardAcknowledgement; + const bounds = ledger?.toggleButtonBounds; + return ( + activeScenes.includes('CampScene') && + arrival?.battleId === expectedBattleId && + arrival.blocking === false && + ledger?.role === 'on-demand-ledger' && + ledger.available === true && + ledger.visible === false && + ledger.battleId === expectedBattleId && + ledger.toggleInteractive === true && + Number.isFinite(bounds?.x) && + Number.isFinite(bounds?.y) && + Number.isFinite(bounds?.width) && + Number.isFinite(bounds?.height) && + bounds.width > 0 && + bounds.height > 0 + ); + }, battleId, { timeout: 30000 }); + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); +} + async function waitForCampSkinTransition(page, expectedSkinId) { await page.waitForFunction((skinId) => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; @@ -10798,6 +11125,43 @@ async function readSortieOrderControlPoint(page, control, orderId) { return probe; } +async function readCampVictoryRewardLedgerTogglePoint(page) { + const probe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const canvas = document.querySelector('canvas'); + const ledger = window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement; + const bounds = ledger?.toggleButtonBounds; + if ( + !scene || + !canvas || + !bounds || + ledger?.role !== 'on-demand-ledger' || + ledger.available !== true || + ledger.visible !== false || + ledger.toggleInteractive !== true + ) { + return { ready: false, ledger, bounds: bounds ?? null }; + } + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + bounds, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }); + assert( + probe.ready === true && + Number.isFinite(probe.x) && + Number.isFinite(probe.y) && + boundsWithinFhdViewport(probe.bounds), + `Expected an FHD-safe interactive camp reward-ledger toggle: ${JSON.stringify(probe)}` + ); + return probe; +} + async function readCampVictoryRewardControlPoint(page, actionId) { const probe = await page.evaluate((requestedActionId) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); @@ -12194,6 +12558,40 @@ function sameJsonValue(value, expected) { return stableJson(value) === stableJson(expected); } +function assertBattleResultDebriefText(resultTexts, rewards, context) { + const texts = Array.isArray(resultTexts) ? resultTexts : []; + const recruits = rewards?.recruits ?? []; + const unlocks = rewards?.unlocks ?? []; + const progressCount = recruits.length + unlocks.length; + const expectedProgressLine = progressCount > 0 + ? `신규 합류 ${recruits.length}명 · 새 전장 ${unlocks.length}곳` + : '새로운 합류·해금 없음'; + const hiddenProgressNames = [ + ...recruits.map((recruit) => recruit.name), + ...unlocks.map((unlock) => unlock.title) + ].filter((label) => typeof label === 'string' && label.length > 0); + const progressSummaryVisible = progressCount > 0 + ? texts.some( + (text) => + text === expectedProgressLine || + text.startsWith(`신규 합류 ${recruits.length}명 · 새`) + ) + : texts.includes(expectedProgressLine); + + assert( + ['소요 턴', '생존 아군', '격파 적군', '달성 목표'].every((label) => texts.includes(label)) && + progressSummaryVisible && + hiddenProgressNames.every( + (label) => !texts.some((text) => text.includes(label)) + ), + `Expected ${context} debrief to end its four metrics with achieved objectives and summarize recruits/unlocks only by count: ${JSON.stringify({ + expectedProgressLine, + hiddenProgressNames, + texts + })}` + ); +} + function campaignSaveWithoutCampRecruitmentEffects(save) { const stableState = (state) => { if (!state) { @@ -12248,24 +12646,6 @@ function campaignVictoryNoticeDismissalSnapshot(save, battleId) { }; } -function campaignSaveWithoutVictoryRewardNoticeDismissals(save) { - const stableState = (state) => { - if (!state) { - return state; - } - const { - updatedAt: _updatedAt, - dismissedVictoryRewardNoticeBattleIds: _dismissedVictoryRewardNoticeBattleIds, - ...stableFields - } = state; - return stableFields; - }; - return { - current: stableState(save?.current), - slot1: stableState(save?.slot1) - }; -} - function campaignSaveWithoutVictoryRewardAcknowledgementEffects(save) { const stableState = (state) => { if (!state) { diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 94823fc..202af1d 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -28,6 +28,7 @@ const checks = [ 'scripts/verify-prologue-village-data.mjs', 'scripts/verify-first-battle-camp-followup.mjs', 'scripts/verify-first-battle-camaraderie-memory.mjs', + 'scripts/verify-first-victory-debrief-hierarchy.mjs', 'scripts/verify-first-pursuit-scout-memory.mjs', 'scripts/verify-second-battle-relief-exploration.mjs', 'scripts/verify-third-battle-return-dialogue.mjs', diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 70fbd6c..25548ac 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -160,7 +160,7 @@ const sortieFlows: Record = { eyebrow: '다음 전장', title: secondBattleScenario.title, description: '탁현에서 선봉을 잃고 퇴각한 한석이 잔당을 이끌고 북쪽 나루와 마을을 붙잡았습니다. 첫 승리 보상으로 정비를 마친 뒤, 세 형제가 한석의 퇴로를 따라 추격합니다.', - rewardHint: '지난 보상: 콩, 탁주, 연습검 · 다음 보상: 소모품, 황건 부적, 의용군 명성', + rewardHint: '다음 보상: 소모품, 황건 부적, 의용군 명성', nextBattleId: secondBattleScenario.id, campaignStep: 'second-battle', pages: secondBattleIntroPages @@ -170,7 +170,7 @@ const sortieFlows: Record = { eyebrow: '다음 전장', title: thirdBattleScenario.title, description: '북쪽 마을을 구했지만 황건 전령이 광종 본영으로 향하고 있습니다. 나루 추격전에서 얻은 보급과 부적을 점검하고, 강가 요새로 이어지는 길을 끊어야 합니다.', - rewardHint: '지난 보상: 콩, 상처약, 탁주, 황건 부적 · 다음 보상: 단궁, 의용군 명성', + rewardHint: '다음 보상: 단궁, 의용군 명성', nextBattleId: thirdBattleScenario.id, campaignStep: 'third-battle', pages: thirdBattleIntroPages diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 2a7a4b7..1ccb924 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -15365,6 +15365,7 @@ export class BattleScene extends Phaser.Scene { const totalEnemies = battleUnits.filter((unit) => unit.faction === 'enemy').length; const aliveAllies = allies.filter((unit) => unit.hp > 0).length; const objectives = this.resultObjectives(outcome); + const achievedObjectiveCount = objectives.filter((objective) => objective.achieved).length; const rewardSnapshot = this.resultCampaignRewards(outcome); const shade = this.trackResultObject(this.add.rectangle(0, 0, battleReferenceWidth, battleReferenceHeight, 0x020406, 0.68)); @@ -15388,7 +15389,7 @@ export class BattleScene extends Phaser.Scene { title.setOrigin(0.5, 0); title.setDepth(depth + 2); - const subtitleText = this.resultSubtitle(outcome, rewardSnapshot); + const subtitleText = this.resultSubtitle(outcome); const subtitle = this.trackResultObject(this.add.text(left + panelWidth / 2, top + 72, subtitleText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', @@ -15409,7 +15410,7 @@ export class BattleScene extends Phaser.Scene { this.renderResultMetric('소요 턴', `${this.turnNumber}턴`, left + 44, metricTop, metricWidth, depth + 2); this.renderResultMetric('생존 아군', `${aliveAllies} / ${allies.length}`, left + 44 + (metricWidth + metricGap), metricTop, metricWidth, depth + 2); this.renderResultMetric('격파 적군', `${defeatedEnemies} / ${totalEnemies}`, left + 44 + (metricWidth + metricGap) * 2, metricTop, metricWidth, depth + 2); - this.renderResultMetric('전투 보상', outcome === 'victory' ? `군자금 ${this.resultDisplayedGold(outcome)}` : '없음', left + 44 + (metricWidth + metricGap) * 3, metricTop, metricWidth, depth + 2); + this.renderResultMetric('달성 목표', `${achievedObjectiveCount} / ${objectives.length}`, left + 44 + (metricWidth + metricGap) * 3, metricTop, metricWidth, depth + 2); this.renderResultObjectivePanel(objectives, left + 44, top + 178, 456, depth + 2); this.renderResultRewardPanel(outcome, objectives, rewardSnapshot, left + 526, top + 178, 470, depth + 2); @@ -16051,13 +16052,11 @@ export class BattleScene extends Phaser.Scene { this.resultSortieOrder = persistedReport.sortieOrder ?? sortieOrder; } - private resultSubtitle(outcome: BattleOutcome, rewards: CampaignRewardSnapshot) { + private resultSubtitle(outcome: BattleOutcome) { const base = outcome !== 'victory' ? `${battleScenario.title}에서 패배했습니다. 진형과 보급을 정비한 뒤 다시 도전해야 합니다.` - : rewards.unlocks[0]?.title - ? `${battleScenario.title} 승리. 보상 정산 후 ${rewards.unlocks[0].title}로 이어집니다.` - : `${battleScenario.title} 승리. 획득 보상과 장수 성장을 정산합니다.`; + : `${battleScenario.title} 승리. 보상 정산 후 승리 후일담으로 이어집니다.`; const preparation = this.thirdCampPreparationPresentation('result'); const xuzhouResult = @@ -17061,10 +17060,10 @@ export class BattleScene extends Phaser.Scene { ...rewards.equipment, ...rewards.reputation ].join(' · '); - const progressLine = [ - rewards.recruits.length > 0 ? `${rewards.recruits.map((recruit) => recruit.name).join('·')} 합류` : '', - rewards.unlocks.length > 0 ? `${rewards.unlocks.map((unlock) => unlock.title).join('·')} 해금` : '' - ].filter(Boolean).join(' · '); + const progressCount = rewards.recruits.length + rewards.unlocks.length; + const progressLine = progressCount > 0 + ? `신규 합류 ${rewards.recruits.length}명 · 새 전장 ${rewards.unlocks.length}곳` + : '새로운 합류·해금 없음'; const rewardCards: Array<{ label: string; value: string; @@ -17098,8 +17097,8 @@ export class BattleScene extends Phaser.Scene { }, { label: '새 전력 · 해금', - value: progressLine || rewards.note || '새 해금 없음', - badge: rewards.recruits.length + rewards.unlocks.length > 0 ? 'NEW' : '변화 없음', + value: progressLine, + badge: progressCount > 0 ? 'NEW' : '변화 없음', icon: 'success', accent: palette.green } diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 599122b..6b70297 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -358,7 +358,16 @@ type CampTabButtonView = { type VictoryRewardCardId = CampaignVictoryRewardCategoryId; -type VictoryRewardActionId = 'equipment' | 'supplies' | 'guided' | 'sortie' | 'close'; +type VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close'; + +type VictorySettlementArrival = { + battleId: string; + battleTitle: string; + message: string; + settledBattleIds: string[]; + pendingCategories: CampaignVictoryRewardCategoryId[]; + passiveCategoriesAcknowledged: CampaignVictoryRewardCategoryId[]; +}; type VictoryRewardIcon = | { kind: 'mark'; mark: string } @@ -11471,9 +11480,12 @@ export class CampScene extends Phaser.Scene { private navigationBlockerObjects: Phaser.GameObjects.GameObject[] = []; private pendingSaveSlot?: number; private pendingVictoryRewardReport?: CampaignVictoryRewardReport; + private victoryRewardLedgerReport?: CampaignVictoryRewardReport; + private victorySettlementArrival?: VictorySettlementArrival; private victoryRewardObjects: Phaser.GameObjects.GameObject[] = []; private victoryRewardCards: VictoryRewardCardView[] = []; private victoryRewardActions: VictoryRewardActionView[] = []; + private reportVictoryRewardLedgerButton?: Phaser.GameObjects.Rectangle; private reportFormationReviewObjects: Phaser.GameObjects.GameObject[] = []; private reportFormationReviewVisible = false; private reportFormationReviewFeedback = ''; @@ -11635,9 +11647,12 @@ export class CampScene extends Phaser.Scene { this.navigationBlockerObjects = []; this.pendingSaveSlot = undefined; this.pendingVictoryRewardReport = undefined; + this.victoryRewardLedgerReport = undefined; + this.victorySettlementArrival = undefined; this.victoryRewardObjects = []; this.victoryRewardCards = []; this.victoryRewardActions = []; + this.reportVictoryRewardLedgerButton = undefined; this.equipmentSwapConfirmObjects = []; this.pendingEquipmentSwap = undefined; this.equipmentSwapConfirmPanel = undefined; @@ -11676,6 +11691,9 @@ export class CampScene extends Phaser.Scene { this.campaign = getCampaignState(); this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport(); this.pendingVictoryRewardReport = getPendingCampaignVictoryRewardNoticeReport(); + if (this.pendingVictoryRewardReport) { + this.settleVictoryRewardArrival(this.pendingVictoryRewardReport); + } if (!this.retrySortieBattleId) { this.ensureCurrentCampRecruitment(); } @@ -11875,8 +11893,12 @@ export class CampScene extends Phaser.Scene { this.renderStaticButtons(); this.render(); - if (this.pendingVictoryRewardReport) { - this.time.delayedCall(0, () => this.showVictoryRewardAcknowledgement()); + if (this.victorySettlementArrival) { + this.time.delayedCall(0, () => { + if (this.victorySettlementArrival) { + this.showCampNotice(this.victorySettlementArrival.message); + } + }); } else if (this.openSortiePrepOnCreate) { this.time.delayedCall(0, () => { this.showRequestedSortiePrep(); @@ -12714,8 +12736,8 @@ export class CampScene extends Phaser.Scene { } const achieved = this.report.objectives.filter((objective) => objective.achieved).length; - const gold = this.campaign?.gold ?? this.report.rewardGold; - return `${this.report.turnNumber}턴 승리 · 군자금 ${gold} · 목표 ${achieved}/${this.report.objectives.length}`; + const outcome = this.report.outcome === 'victory' ? '승리' : '패배'; + return `${this.report.turnNumber}턴 ${outcome} · 목표 ${achieved}/${this.report.objectives.length} · 군영 준비 중`; } private currentCampTitle() { @@ -13576,13 +13598,14 @@ export class CampScene extends Phaser.Scene { this.pendingSaveSlot = undefined; } - private showVictoryRewardAcknowledgement() { - let report = this.pendingVictoryRewardReport ?? getPendingCampaignVictoryRewardNoticeReport(); - if (!report || report.outcome !== 'victory' || this.sortieObjects.length > 0) { + private settleVictoryRewardArrival(report: CampaignVictoryRewardReport) { + if (report.outcome !== 'victory') { + this.pendingVictoryRewardReport = undefined; return; } - this.hideVictoryRewardAcknowledgement(); + const passiveCategoriesAcknowledged = getPendingCampaignVictoryRewardCategories(report.battleId) + .filter((category) => category === 'gold' || category === 'reputation'); getPendingCampaignVictoryRewardBattleIds().forEach((battleId) => { const passiveCategories = getPendingCampaignVictoryRewardCategories(battleId) .filter((category) => category === 'gold' || category === 'reputation'); @@ -13590,12 +13613,42 @@ export class CampScene extends Phaser.Scene { this.campaign = acknowledgeCampaignVictoryReward(battleId, passiveCategories); } }); - report = getPendingCampaignVictoryRewardNoticeReport(); - if (!report) { - this.pendingVictoryRewardReport = undefined; + + const settledBattleIds: string[] = []; + const seenBattleIds = new Set(); + seenBattleIds.add(report.battleId); + settledBattleIds.push(report.battleId); + this.campaign = dismissCampaignVictoryRewardNotice(report.battleId); + let pendingNotice = getPendingCampaignVictoryRewardNoticeReport(); + while (pendingNotice && !seenBattleIds.has(pendingNotice.battleId)) { + seenBattleIds.add(pendingNotice.battleId); + settledBattleIds.push(pendingNotice.battleId); + this.campaign = dismissCampaignVictoryRewardNotice(pendingNotice.battleId); + pendingNotice = getPendingCampaignVictoryRewardNoticeReport(); + } + + const message = + `${report.battleTitle} 정산 완료 · 세부 획득 내역은 하단 전투 보고의 보상 장부에 기록했습니다.\n` + + 'NEW 표식이 있는 준비 화면과 상단 다음 할 일을 따라 군영을 둘러보십시오.'; + this.victorySettlementArrival = { + battleId: report.battleId, + battleTitle: report.battleTitle, + message, + settledBattleIds, + pendingCategories: getPendingCampaignVictoryRewardCategories(report.battleId), + passiveCategoriesAcknowledged, + }; + this.pendingVictoryRewardReport = undefined; + } + + private showVictoryRewardAcknowledgement(reportOverride?: CampaignVictoryRewardReport) { + const report = reportOverride ?? this.report ?? getPendingCampaignVictoryRewardNoticeReport(); + if (!report || report.outcome !== 'victory' || this.sortieObjects.length > 0) { return; } - this.pendingVictoryRewardReport = report; + + this.hideVictoryRewardAcknowledgement(); + this.victoryRewardLedgerReport = report; const rewards = report.campaignRewards; const presentation = campaignVictoryRewardPresentation(report); const pendingCategorySet = new Set(getPendingCampaignVictoryRewardCategories(report.battleId)); @@ -13617,9 +13670,6 @@ export class CampScene extends Phaser.Scene { const panelY = 90; const panelWidth = 884; const panelHeight = 546; - const firstCampGuidedAction = report.battleId === campBattleIds.first - ? this.firstCampGuidedAction() - : undefined; const shade = this.trackVictoryReward( this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.78) @@ -13637,12 +13687,12 @@ export class CampScene extends Phaser.Scene { panel.setInteractive(); const eyebrow = this.trackVictoryReward( - this.add.text(panelX + 34, panelY + 24, 'VICTORY REWARD', this.textStyle(11, '#d8b15f', true)) + this.add.text(panelX + 34, panelY + 24, 'BATTLE LEDGER', this.textStyle(11, '#d8b15f', true)) ); eyebrow.setDepth(depth + 2); const title = this.trackVictoryReward( - this.add.text(panelX + 34, panelY + 45, '이번 승전 반영', this.textStyle(30, '#f2e3bf', true)) + this.add.text(panelX + 34, panelY + 45, '보상 장부', this.textStyle(30, '#f2e3bf', true)) ); title.setDepth(depth + 2); @@ -13656,7 +13706,7 @@ export class CampScene extends Phaser.Scene { this.add.text( panelX + 34, panelY + 88, - `${report.battleTitle} 승리 보상이 군영 자산에 반영되었습니다.`, + `${report.battleTitle}에서 획득한 세부 내역입니다. 군영에서 언제든 다시 확인할 수 있습니다.`, this.textStyle(14, '#d4dce6', true) ) ); @@ -13822,7 +13872,7 @@ export class CampScene extends Phaser.Scene { this.add.text( panelX + 34, panelY + 378, - note ? `전황 기록 · ${note}` : '보상 확인 후 필요한 정비 화면으로 바로 이동할 수 있습니다.', + note ? `전황 기록 · ${note}` : 'NEW 표시는 각 준비 화면에서 해당 자산을 확인하면 해제됩니다.', { ...this.textStyle(12, note ? '#ffdf7b' : '#9fb0bf', Boolean(note)), wordWrap: { width: panelWidth - 68, useAdvancedWrap: true } @@ -13831,24 +13881,15 @@ export class CampScene extends Phaser.Scene { ); noteText.setDepth(depth + 2); - const actionDefinitions: Array<{ id: VictoryRewardActionId; label: string; primary?: boolean }> = - firstCampGuidedAction - ? [ - { id: 'equipment', label: '장비 확인' }, - { id: 'supplies', label: '보급 확인' }, - { id: 'guided', label: firstCampGuidedAction.label, primary: true }, - { id: 'sortie', label: '바로 출진' }, - { id: 'close', label: '닫기' } - ] - : [ - { id: 'equipment', label: '장비 확인' }, - { id: 'supplies', label: '보급 확인' }, - { id: 'sortie', label: '출진 준비', primary: true }, - { id: 'close', label: '닫기' } - ]; - const buttonWidth = firstCampGuidedAction ? 156 : 158; + const actionDefinitions: Array<{ id: VictoryRewardActionId; label: string; primary?: boolean }> = [ + { id: 'equipment', label: '장비 확인' }, + { id: 'supplies', label: '보급 확인' }, + { id: 'sortie', label: '출진 준비' }, + { id: 'close', label: '장부 닫기', primary: true } + ]; + const buttonWidth = 158; const buttonHeight = 48; - const actionGap = firstCampGuidedAction ? 8 : 14; + const actionGap = 14; const totalActionWidth = actionDefinitions.length * buttonWidth + (actionDefinitions.length - 1) * actionGap; const actionStartX = panelX + Math.floor((panelWidth - totalActionWidth) / 2); actionDefinitions.forEach((definition, index) => { @@ -13869,11 +13910,7 @@ export class CampScene extends Phaser.Scene { buttonX + buttonWidth / 2, buttonY + buttonHeight / 2, definition.label, - this.textStyle( - definition.id === 'guided' && Array.from(definition.label).length > 8 ? 12 : 14, - definition.primary ? '#fff2b8' : '#f2e3bf', - true - ) + this.textStyle(14, definition.primary ? '#fff2b8' : '#f2e3bf', true) ) ); label.setOrigin(0.5); @@ -13896,14 +13933,13 @@ export class CampScene extends Phaser.Scene { primary: Boolean(definition.primary) }); }); - soundDirector.playRewardRevealCue(); } private completeVictoryRewardAcknowledgement( action: VictoryRewardActionId, categories = this.victoryRewardCategoriesForAction(action) ) { - const report = this.pendingVictoryRewardReport; + const report = this.victoryRewardLedgerReport; const rewardedRecruitId = report?.campaignRewards?.recruits[0]?.unitId; if (action === 'sortie' && rewardedRecruitId && this.sortieAllies().some((unit) => unit.id === rewardedRecruitId)) { this.focusVictoryRewardRecruit(rewardedRecruitId); @@ -13930,11 +13966,7 @@ export class CampScene extends Phaser.Scene { this.render(); return; } - if (action === 'guided') { - this.runFirstCampGuidedAction(); - return; - } - if (action === 'sortie' || (action === 'close' && this.openSortiePrepOnCreate)) { + if (action === 'sortie') { this.showRequestedSortiePrep(); } } @@ -13981,6 +14013,7 @@ export class CampScene extends Phaser.Scene { this.victoryRewardObjects = []; this.victoryRewardCards = []; this.victoryRewardActions = []; + this.victoryRewardLedgerReport = undefined; } private rewardInventoryLabel(label: string) { @@ -21950,6 +21983,7 @@ export class CampScene extends Phaser.Scene { return; } + this.reportVictoryRewardLedgerButton = undefined; this.reportFormationReviewToggleButton = undefined; this.reportFormationHistoryToggleButton = undefined; this.reportSortieOrderRewardBadge = undefined; @@ -21964,15 +21998,22 @@ export class CampScene extends Phaser.Scene { this.add.text( x + 18, y + 39, - this.compactText(`MVP ${report.mvp?.name ?? '-'} · 전투 보상 ${report.rewardGold} · 전리품 ${report.itemRewards.join(', ') || '없음'}`, 56), + this.compactText( + `${report.outcome === 'victory' ? '승리' : '패배'} · ${report.battleTitle} · ${report.turnNumber}턴 · MVP ${report.mvp?.name ?? '-'}`, + 56 + ), this.textStyle(14, '#d4dce6') ) ); + const achievedObjectiveCount = report.objectives.filter((objective) => objective.achieved).length; this.track( this.add.text( x + 18, y + 62, - this.compactText(this.reportCampaignRewardLine(report), 64), + this.compactText( + `목표 ${achievedObjectiveCount}/${report.objectives.length} · 격파 ${report.defeatedEnemies}/${report.totalEnemies} · 세부 보상과 전과는 보상 장부에서 확인`, + 64 + ), this.textStyle(12, '#d8b15f', true) ) ); @@ -21997,7 +22038,7 @@ export class CampScene extends Phaser.Scene { this.add.text( x + 998, y + 11, - this.compactText(`첫 공훈 · ${definition.label} · 군자금 +${definition.rewardGold} · ${definition.rewardItems.join(', ')}`, 44), + this.compactText(`첫 공훈 · ${definition.label} · 추가 보상 반영 완료`, 44), this.textStyle(9, '#fff2b8', true) ) ).setOrigin(0.5); @@ -22008,6 +22049,14 @@ export class CampScene extends Phaser.Scene { const recommendationOutcome = review ? this.reportSortieRecommendationOutcome(review) : undefined; const historyRecords = this.reportFormationHistoryRecords(); const actions: { label: string; action: () => void; assign: (button: Phaser.GameObjects.Rectangle) => void }[] = []; + if (report.outcome === 'victory') { + const pendingRewardCount = getPendingCampaignVictoryRewardCategories(report.battleId).length; + actions.push({ + label: pendingRewardCount > 0 ? `보상 장부 NEW ${pendingRewardCount}` : '보상 장부', + action: () => this.showVictoryRewardAcknowledgement(report), + assign: (button) => { this.reportVictoryRewardLedgerButton = button; } + }); + } if (review) { actions.push({ label: recommendationOutcome ? `추천 검증 ${recommendationOutcome.statusLabel}` : `최근 평가 ${review.grade}`, @@ -22404,25 +22453,6 @@ export class CampScene extends Phaser.Scene { return background; } - private reportCampaignRewardLine(report: FirstBattleReport) { - const rewards = report.campaignRewards; - if (!rewards) { - return '캠페인 보상: 기존 전리품이 군영 장부에 반영되었습니다.'; - } - - const recruits = rewards.recruits ?? []; - const equipment = rewards.equipment ?? []; - const reputation = rewards.reputation ?? []; - const unlocks = rewards.unlocks ?? []; - const parts = [ - recruits.length > 0 ? `합류 ${recruits.map((recruit) => recruit.name).join(', ')}` : '', - equipment.length > 0 ? `장비 ${equipment.join(', ')}` : '', - reputation.length > 0 ? `명성 ${reputation.join(', ')}` : '', - unlocks.length > 0 ? `다음 ${unlocks.map((unlock) => unlock.title).join(', ')}` : '' - ].filter(Boolean); - return parts.length > 0 ? `캠페인 반영: ${parts.join(' · ')}` : '캠페인 보상: 추가 해금 없음'; - } - private showReportFormationReview() { if (!this.reportFormationReview()) { return; @@ -26770,9 +26800,24 @@ export class CampScene extends Phaser.Scene { return { scene: this.scene.key, visualMotionReduced: this.visualMotionReduced, + victorySettlementArrival: this.victorySettlementArrival + ? { + battleId: this.victorySettlementArrival.battleId, + battleTitle: this.victorySettlementArrival.battleTitle, + message: this.victorySettlementArrival.message, + settledBattleIds: [...this.victorySettlementArrival.settledBattleIds], + pendingCategories: [...this.victorySettlementArrival.pendingCategories], + passiveCategoriesAcknowledged: [...this.victorySettlementArrival.passiveCategoriesAcknowledged], + blocking: false + } + : null, victoryRewardAcknowledgement: { + role: 'on-demand-ledger', + available: this.report?.outcome === 'victory', visible: this.victoryRewardObjects.length > 0, - battleId: this.pendingVictoryRewardReport?.battleId ?? null, + battleId: this.victoryRewardLedgerReport?.battleId ?? this.report?.battleId ?? null, + toggleButtonBounds: this.sortieObjectBoundsDebug(this.reportVictoryRewardLedgerButton), + toggleInteractive: Boolean(this.reportVictoryRewardLedgerButton?.input?.enabled), cards: this.victoryRewardCards.map((card) => ({ id: card.id, label: card.label, diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index ef02e08..463228f 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -1,7 +1,6 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { musicTracks, type MusicTrackKey } from '../audio/audioAssets'; -import { equipmentItemIdForInventoryLabel } from '../data/battleItems'; import { battleUiIconFrames, battleUiIconMicroTextureKey, @@ -57,11 +56,7 @@ import { } from '../data/storyCutscenes'; import { prologueOpeningPages } from '../data/prologueVillage'; import { type PortraitKey, type StoryPage } from '../data/scenario'; -import { - campaignVictoryRewardPresentation, - getCampaignState, - getFirstBattleReport -} from '../state/campaignState'; +import { getCampaignState, getFirstBattleReport } from '../state/campaignState'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; import { fitDialogueFacePortrait } from '../ui/dialoguePortrait'; @@ -1981,51 +1976,12 @@ export class StoryScene extends Phaser.Scene { return fallback; } - const rewards = report.campaignRewards; - const presentation = campaignVictoryRewardPresentation(report); - const categorizedRewardItems = new Set([ - ...(rewards?.supplies ?? []), - ...(rewards?.equipment ?? []), - ...(rewards?.reputation ?? []) - ]); - const legacyExtraItems = report.itemRewards.filter((item) => !categorizedRewardItems.has(item)); - const supplyItems = rewards - ? [ - ...rewards.supplies, - ...legacyExtraItems, - ...(presentation.sortieOrderBonus?.rewardItems ?? []) - ] - : [...presentation.itemRewards]; - if (!rewards && page.id !== 'first-victory-village') { - return fallback; - } - - let items: StoryRewardDisplayItem[] = []; - if (page.id === 'first-victory-village') { - items = [this.goldRewardItem(presentation.rewardGold), ...supplyItems.map((label, index) => this.supplyRewardItem(label, index))]; - } else if (page.id === 'first-victory-liu-bei' && rewards) { - items = [ - ...rewards.equipment.map((label, index) => this.equipmentRewardItem(label, index)), - ...rewards.reputation.map((label, index) => this.reputationRewardItem(label, index)) - ]; - } else if (page.id === 'first-victory-guan-yu' && rewards) { - items = rewards.recruits - .filter((recruit) => recruit.unitId === 'guan-yu') - .map((recruit) => this.recruitRewardItem(recruit.unitId, recruit.name)); - } else if (page.id === 'first-victory-zhang-fei' && rewards) { - items = rewards.recruits - .filter((recruit) => recruit.unitId !== 'guan-yu') - .map((recruit) => this.recruitRewardItem(recruit.unitId, recruit.name)); - } else if (page.id === 'first-victory-next' && rewards) { - items = rewards.unlocks.map((unlock) => this.unlockRewardItem(unlock.battleId, unlock.title)); - } - return { pageId: page.id, source: 'campaign', battleId: report.battleId, - rewardGold: presentation.rewardGold, - items + rewardGold: null, + items: [] }; } @@ -2051,77 +2007,6 @@ export class StoryScene extends Phaser.Scene { return { pageId: null, source: 'none', battleId: null, rewardGold: null, items: [] }; } - private goldRewardItem(amount: number): StoryRewardDisplayItem { - return { - id: 'gold', - category: 'gold', - eyebrow: '군자금', - label: `+${Math.max(0, amount).toLocaleString('ko-KR')}`, - tone: 'reward', - icon: { kind: 'mark', fallbackMark: '金' } - }; - } - - private supplyRewardItem(label: string, index: number): StoryRewardDisplayItem { - const iconKey: BattleUiIconKey = /탁주/.test(label) ? 'wine' : /상처약/.test(label) ? 'salve' : 'bean'; - return { - id: `supply-${index}-${label}`, - category: 'supply', - eyebrow: '보급품', - label, - tone: 'reward', - icon: { kind: 'battle-ui', iconKey, fallbackMark: /탁주/.test(label) ? '酒' : /상처약/.test(label) ? '藥' : '糧' } - }; - } - - private equipmentRewardItem(label: string, index: number): StoryRewardDisplayItem { - const inventoryLabel = label.replace(/\s+(?:[x×]\s*)?\d+\s*$/u, '').trim(); - const itemId = equipmentItemIdForInventoryLabel(inventoryLabel); - return { - id: `equipment-${index}-${label}`, - category: 'equipment', - eyebrow: '장비 획득', - label, - tone: 'reward', - icon: itemId - ? { kind: 'item', textureKey: `item-${itemId}`, fallbackMark: '具' } - : { kind: 'battle-ui', iconKey: 'sword', fallbackMark: '具' } - }; - } - - private reputationRewardItem(label: string, index: number): StoryRewardDisplayItem { - return { - id: `reputation-${index}-${label}`, - category: 'reputation', - eyebrow: '명성 상승', - label, - tone: 'bond', - icon: { kind: 'mark', fallbackMark: '名' } - }; - } - - private recruitRewardItem(unitId: string, name: string): StoryRewardDisplayItem { - return { - id: `recruit-${unitId}`, - category: 'recruit', - eyebrow: '신규 합류', - label: `${name} 출전 가능`, - tone: 'bond', - icon: { kind: 'mark', fallbackMark: '將' } - }; - } - - private unlockRewardItem(battleId: string, title: string): StoryRewardDisplayItem { - return { - id: `unlock-${battleId}`, - category: 'unlock', - eyebrow: '전장 해금', - label: title, - tone: 'next', - icon: { kind: 'battle-ui', iconKey: 'success', fallbackMark: '開' } - }; - } - private renderRewardIcon(icon: StoryRewardIcon, x: number, y: number, size: number, toneColor: number) { if (icon.kind === 'item' && this.textures.exists(icon.textureKey)) { const image = this.add.image(x, y, icon.textureKey);