diff --git a/package.json b/package.json index d9092d6..45b381f 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,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-pursuit-scout": "node scripts/verify-first-pursuit-scout-memory.mjs", "verify:prologue-exploration-assets": "node scripts/verify-prologue-exploration-asset-data.mjs", "verify:exploration-characters": "node scripts/verify-exploration-character-asset-data.mjs", "verify:prologue-dialogue-portraits": "node scripts/verify-prologue-dialogue-portrait-data.mjs", diff --git a/scripts/verify-first-pursuit-scout-memory.mjs b/scripts/verify-first-pursuit-scout-memory.mjs new file mode 100644 index 0000000..3290dcd --- /dev/null +++ b/scripts/verify-first-pursuit-scout-memory.mjs @@ -0,0 +1,332 @@ +import { readFile } from 'node:fs/promises'; +import { createServer } from 'vite'; + +const storage = new Map(); +globalThis.window = { + localStorage: { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(key, String(value)); + }, + removeItem(key) { + storage.delete(key); + }, + clear() { + storage.clear(); + } + } +}; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const memoryModule = await server.ssrLoadModule( + '/src/game/data/firstPursuitScoutMemory.ts' + ); + const campaignModule = await server.ssrLoadModule( + '/src/game/state/campaignState.ts' + ); + const { battleScenarios } = await server.ssrLoadModule( + '/src/game/data/battles.ts' + ); + + verifyCanonicalIds(memoryModule); + verifyValidChoices(memoryModule, campaignModule, battleScenarios); + verifyInvalidMarkers(memoryModule, campaignModule, battleScenarios); + verifyTargetIsolation(memoryModule, campaignModule, battleScenarios); + await verifyCampSceneLiteralSync(memoryModule); + + console.log( + 'First-pursuit scout memory verification passed (canonical visit data, strict marker validation, target isolation, and CampScene literal sync).' + ); +} finally { + await server.close(); +} + +function verifyCanonicalIds(memoryModule) { + assert( + memoryModule.firstPursuitScoutVisitId === 'first-pursuit-scout-tent', + 'The first-pursuit scout visit id must remain stable.' + ); + assert( + memoryModule.firstPursuitScoutSourceBattleId === + 'first-battle-zhuo-commandery' && + memoryModule.firstPursuitScoutTargetBattleId === + 'second-battle-yellow-turban-pursuit', + 'The scout memory must remain scoped from the first victory to the second battle.' + ); + assert( + JSON.stringify(memoryModule.firstPursuitScoutChoiceIds) === + JSON.stringify(['trace-river-ambush', 'mark-village-relief']), + 'The two canonical scout choice ids must remain stable and ordered.' + ); +} + +function verifyValidChoices(memoryModule, campaignModule, battleScenarios) { + const memories = memoryModule.firstPursuitScoutChoiceIds.map((choiceId) => { + const campaign = createCompletedScoutCampaign( + memoryModule, + campaignModule, + battleScenarios, + choiceId + ); + const memory = memoryModule.resolveFirstPursuitScoutMemory({ + campaign, + battleId: memoryModule.firstPursuitScoutTargetBattleId + }); + assert( + memory?.visitId === memoryModule.firstPursuitScoutVisitId && + memory.choiceId === choiceId && + memory.choiceLabel.length > 0 && + memory.deploymentIntentPreview === true && + memory.campSummaryLine.startsWith('군영 준비 · ') && + memory.openingLines.length === 2 && + memory.openingLines[0] === memory.campSummaryLine, + `A completed canonical choice must resolve a concise target-battle payoff: ${JSON.stringify(memory)}` + ); + return memory; + }); + + assert( + memories[0].campSummaryLine !== memories[1].campSummaryLine && + memories[0].openingLines[1] !== memories[1].openingLines[1], + 'Each scout choice must return distinct camp and opening guidance.' + ); + + memories[0].openingLines[0] = 'mutated outside resolver'; + const campaign = createCompletedScoutCampaign( + memoryModule, + campaignModule, + battleScenarios, + memoryModule.firstPursuitScoutChoiceIds[0] + ); + const resolvedAgain = memoryModule.resolveFirstPursuitScoutMemory({ + campaign, + battleId: memoryModule.firstPursuitScoutTargetBattleId + }); + assert( + resolvedAgain?.openingLines[0] === resolvedAgain?.campSummaryLine, + 'Resolved opening lines must be cloned instead of exposing canonical data.' + ); +} + +function verifyInvalidMarkers( + memoryModule, + campaignModule, + battleScenarios +) { + const valid = createCompletedScoutCampaign( + memoryModule, + campaignModule, + battleScenarios, + memoryModule.firstPursuitScoutChoiceIds[0] + ); + const sourceBattleId = memoryModule.firstPursuitScoutSourceBattleId; + const targetBattleId = memoryModule.firstPursuitScoutTargetBattleId; + const visitId = memoryModule.firstPursuitScoutVisitId; + const cases = [ + { + label: 'missing source settlement', + mutate(campaign) { + delete campaign.battleHistory[sourceBattleId]; + } + }, + { + label: 'source defeat', + mutate(campaign) { + campaign.battleHistory[sourceBattleId].outcome = 'defeat'; + } + }, + { + label: 'mismatched source settlement id', + mutate(campaign) { + campaign.battleHistory[sourceBattleId].battleId = targetBattleId; + } + }, + { + label: 'missing visit completion', + mutate(campaign) { + campaign.completedCampVisits = []; + } + }, + { + label: 'lookalike visit completion', + mutate(campaign) { + campaign.completedCampVisits = [`${visitId}-forged`]; + } + }, + { + label: 'missing choice marker', + mutate(campaign) { + delete campaign.campVisitChoiceIds[visitId]; + } + }, + { + label: 'unknown choice marker', + mutate(campaign) { + campaign.campVisitChoiceIds[visitId] = 'reveal-all-enemies'; + } + }, + { + label: 'choice stored under a forged visit key', + mutate(campaign) { + campaign.campVisitChoiceIds[`${visitId}-forged`] = + campaign.campVisitChoiceIds[visitId]; + delete campaign.campVisitChoiceIds[visitId]; + } + } + ]; + + cases.forEach(({ label, mutate }) => { + const campaign = structuredClone(valid); + mutate(campaign); + const memory = memoryModule.resolveFirstPursuitScoutMemory({ + campaign, + battleId: targetBattleId + }); + assert( + memory === undefined, + `Invalid scout state (${label}) must not grant deployment intent preview: ${JSON.stringify(memory)}` + ); + }); +} + +function verifyTargetIsolation( + memoryModule, + campaignModule, + battleScenarios +) { + const campaign = createCompletedScoutCampaign( + memoryModule, + campaignModule, + battleScenarios, + memoryModule.firstPursuitScoutChoiceIds[1] + ); + const unrelatedBattleIds = [ + memoryModule.firstPursuitScoutSourceBattleId, + 'third-battle-guangzong-road' + ]; + unrelatedBattleIds.forEach((battleId) => { + const memory = memoryModule.resolveFirstPursuitScoutMemory({ + campaign, + battleId + }); + assert( + memory === undefined, + `Scout payoff must not leak from the second battle into ${battleId}.` + ); + }); +} + +async function verifyCampSceneLiteralSync(memoryModule) { + const source = await readFile( + new URL('../src/game/scenes/CampScene.ts', import.meta.url), + 'utf8' + ); + const visitsStart = source.indexOf( + 'const campVisits: CampVisitDefinition[] = [' + ); + const visitStart = source.indexOf( + `id: '${memoryModule.firstPursuitScoutVisitId}'`, + visitsStart + ); + assert( + visitsStart >= 0 && visitStart > visitsStart, + 'CampScene must declare the canonical first-pursuit visit inside campVisits.' + ); + + const visitSource = source.slice(visitStart, visitStart + 2600); + assert( + /availableAfterBattleIds:\s*\[\s*campBattleIds\.first\s*\]/.test( + visitSource + ), + 'The canonical scout visit must be available immediately after the first battle.' + ); + memoryModule.firstPursuitScoutChoiceIds.forEach((choiceId) => { + assert( + visitSource.includes(`id: '${choiceId}'`), + `CampScene scout visit is missing canonical choice id ${choiceId}.` + ); + }); + + const campaign = createLiteralSyncCampaign(memoryModule); + memoryModule.firstPursuitScoutChoiceIds.forEach((choiceId) => { + campaign.campVisitChoiceIds[memoryModule.firstPursuitScoutVisitId] = + choiceId; + const memory = memoryModule.resolveFirstPursuitScoutMemory({ + campaign, + battleId: memoryModule.firstPursuitScoutTargetBattleId + }); + assert( + visitSource.includes(`label: '${memory.choiceLabel}'`), + `CampScene label for ${choiceId} must match the canonical memory label.` + ); + }); +} + +function createCompletedScoutCampaign( + memoryModule, + campaignModule, + battleScenarios, + choiceId +) { + storage.clear(); + campaignModule.resetCampaignState(); + const scenario = + battleScenarios[memoryModule.firstPursuitScoutSourceBattleId]; + campaignModule.setFirstBattleReport({ + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: 6, + rewardGold: scenario.baseVictoryGold, + defeatedEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + totalEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + objectives: [], + units: scenario.units, + bonds: scenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })), + itemRewards: [], + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-27T00:00:00.000Z' + }); + const updated = campaignModule.applyCampVisitReward( + memoryModule.firstPursuitScoutVisitId, + {}, + choiceId + ); + assert( + updated, + `Campaign state must accept canonical scout choice ${choiceId}.` + ); + return campaignModule.getCampaignState(); +} + +function createLiteralSyncCampaign(memoryModule) { + return { + battleHistory: { + [memoryModule.firstPursuitScoutSourceBattleId]: { + battleId: memoryModule.firstPursuitScoutSourceBattleId, + outcome: 'victory' + } + }, + completedCampVisits: [memoryModule.firstPursuitScoutVisitId], + campVisitChoiceIds: {} + }; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index e55a88f..b7d7d98 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -15,6 +15,11 @@ const phaserWebglRendererType = 2; const legacyUiScale = 1.5; const expectedTitle = '\uC0BC\uAD6D\uC9C0: \uC138 \uD615\uC81C\uC758 \uB9F9\uC138'; const firstBattleUnlockLabel = '\uD669\uAC74 \uC794\uB2F9 \uCD94\uACA9 \uAC1C\uBC29'; +const firstPursuitScoutVisitId = 'first-pursuit-scout-tent'; +const firstPursuitScoutChoiceId = 'trace-river-ambush'; +const firstPursuitScoutChoiceLabel = '강가 매복 흔적을 쫓는다'; +const firstPursuitScoutCampSummaryLine = '군영 준비 · 강가 매복로 정찰 완료'; +const firstPursuitScoutOpeningGuide = '정찰 인계 · 나루 앞 숲과 궁병 엄호선을 먼저 확인하십시오.'; const relevantLogPattern = /asset|audio|cannot|failed|map|missing|portrait|sprite|story|texture|unit/i; let serverProcess; @@ -2857,6 +2862,128 @@ try { firstCampProbe.state.campTabs.find((tab) => tab.id === 'status')?.indicatorVisible === true, `Expected six distinct, FHD-safe camp tabs with one active state: ${JSON.stringify(firstCampProbe.state?.campTabs)}` ); + assert( + sameJsonValue(firstCampProbe.state?.availableVisitIds, [firstPursuitScoutVisitId]) && + firstCampProbe.state?.completedAvailableVisits?.length === 0 && + firstCampProbe.state?.firstPursuitScoutMemory?.available === true && + firstCampProbe.state.firstPursuitScoutMemory.completed === false && + firstCampProbe.state.firstPursuitScoutMemory.choiceId === null && + firstCampProbe.state.firstPursuitScoutMemory.activeForNextSortie === false && + firstCampProbe.state.firstPursuitScoutMemory.deploymentIntentPreview === false && + sameJsonValue( + firstCampProbe.state.firstPursuitScoutMemory.choices?.map((choice) => choice.id), + [firstPursuitScoutChoiceId, 'mark-village-relief'] + ) && + !firstCampProbe.state?.campaign?.completedCampVisits?.includes(firstPursuitScoutVisitId) && + firstCampProbe.state?.campaign?.campVisitChoiceIds?.[firstPursuitScoutVisitId] === undefined, + `Expected exactly one unfinished scout visit after the first victory, without an early battle payoff: ${JSON.stringify(firstCampProbe.state?.firstPursuitScoutMemory)}` + ); + + const firstCampVisitTabPoint = await readCampTabControlPoint(page, 'visit'); + await page.mouse.click(firstCampVisitTabPoint.x, firstCampVisitTabPoint.y); + await page.waitForFunction( + (visitId) => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return camp?.activeTab === 'visit' && camp?.selectedVisitId === visitId; + }, + firstPursuitScoutVisitId, + { timeout: 30000 } + ); + const firstPursuitScoutChoicePoint = await readCampVisitChoiceControlPoint( + page, + firstPursuitScoutVisitId, + firstPursuitScoutChoiceId + ); + await assertBaselineBrowserViewport(page, 'first-camp scout visit choice'); + await page.screenshot({ path: `${screenshotDir}/rc-first-camp-scout-visit-choice.png`, fullPage: true }); + await assertCanvasPainted(page, 'first-camp scout visit choice'); + + await page.mouse.click(firstPursuitScoutChoicePoint.x, firstPursuitScoutChoicePoint.y); + await page.waitForFunction( + ({ visitId, choiceId }) => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return ( + camp?.completedAvailableVisits?.includes(visitId) && + camp?.campaign?.completedCampVisits?.includes(visitId) && + camp?.campaign?.campVisitChoiceIds?.[visitId] === choiceId && + camp?.firstPursuitScoutMemory?.activeForNextSortie === true + ); + }, + { visitId: firstPursuitScoutVisitId, choiceId: firstPursuitScoutChoiceId }, + { timeout: 30000 } + ); + const completedFirstPursuitScoutVisit = await page.evaluate(({ choiceId }) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp?.(); + const activeTexts = (scene?.contentObjects ?? []) + .filter((object) => object?.type === 'Text' && object.active && object.visible) + .map((object) => object.text); + const choiceLabel = state?.firstPursuitScoutMemory?.choiceLabel ?? ''; + return { + state, + choice: state?.firstPursuitScoutMemory?.choices?.find((candidate) => candidate.id === choiceId) ?? null, + decisionTextCount: activeTexts.filter((text) => text === `나의 결정 · ${choiceLabel}`).length, + choiceControlCount: choiceLabel + ? activeTexts.filter((text) => text.startsWith(choiceLabel)).length + : -1, + preparationSummaryVisible: activeTexts.includes('현지 준비 · 다음 전투 반영'), + visitCompletionTextVisible: activeTexts.includes('방문 1/1 완료') + }; + }, { choiceId: firstPursuitScoutChoiceId }); + const completedFirstPursuitScoutSave = await readCampaignSave(page); + assert( + completedFirstPursuitScoutVisit.state?.activeTab === 'visit' && + completedFirstPursuitScoutVisit.state.selectedVisitId === firstPursuitScoutVisitId && + sameJsonValue(completedFirstPursuitScoutVisit.state.completedAvailableVisits, [firstPursuitScoutVisitId]) && + completedFirstPursuitScoutVisit.state.campaign.completedCampVisits.filter( + (visitId) => visitId === firstPursuitScoutVisitId + ).length === 1 && + completedFirstPursuitScoutVisit.state.report?.completedCampVisits?.filter( + (visitId) => visitId === firstPursuitScoutVisitId + ).length === 1 && + completedFirstPursuitScoutVisit.state.campaign.campVisitChoiceIds[firstPursuitScoutVisitId] === firstPursuitScoutChoiceId && + completedFirstPursuitScoutVisit.state.firstPursuitScoutMemory.completed === true && + completedFirstPursuitScoutVisit.state.firstPursuitScoutMemory.choiceId === firstPursuitScoutChoiceId && + completedFirstPursuitScoutVisit.state.firstPursuitScoutMemory.choiceLabel === firstPursuitScoutChoiceLabel && + completedFirstPursuitScoutVisit.state.firstPursuitScoutMemory.activeForNextSortie === true && + completedFirstPursuitScoutVisit.state.firstPursuitScoutMemory.targetBattleId === 'second-battle-yellow-turban-pursuit' && + completedFirstPursuitScoutVisit.state.firstPursuitScoutMemory.deploymentIntentPreview === true && + completedFirstPursuitScoutVisit.state.firstPursuitScoutMemory.campSummaryLine === firstPursuitScoutCampSummaryLine && + sameJsonValue(completedFirstPursuitScoutVisit.state.firstPursuitScoutMemory.openingLines, [ + firstPursuitScoutCampSummaryLine, + firstPursuitScoutOpeningGuide + ]) && + completedFirstPursuitScoutVisit.choice?.label === firstPursuitScoutChoiceLabel && + completedFirstPursuitScoutVisit.choice?.rewardText.includes('상처약 1') && + completedFirstPursuitScoutVisit.decisionTextCount === 1 && + completedFirstPursuitScoutVisit.choiceControlCount === 0 && + completedFirstPursuitScoutVisit.preparationSummaryVisible === true && + completedFirstPursuitScoutVisit.visitCompletionTextVisible === true, + `Expected the chosen scout visit to become one completed, visible preparation memory: ${JSON.stringify(completedFirstPursuitScoutVisit)}` + ); + assert( + [completedFirstPursuitScoutSave.current, completedFirstPursuitScoutSave.slot1].every((campaign) => ( + campaign?.completedCampVisits?.filter((visitId) => visitId === firstPursuitScoutVisitId).length === 1 && + campaign?.firstBattleReport?.completedCampVisits?.filter((visitId) => visitId === firstPursuitScoutVisitId).length === 1 && + campaign?.campVisitChoiceIds?.[firstPursuitScoutVisitId] === firstPursuitScoutChoiceId + )), + `Expected completedCampVisits and campVisitChoiceIds to persist once in current and slot-1 campaign saves: ${JSON.stringify(completedFirstPursuitScoutSave)}` + ); + await assertBaselineBrowserViewport(page, 'completed first-camp scout visit'); + await page.screenshot({ path: `${screenshotDir}/rc-first-camp-scout-visit-selected.png`, fullPage: true }); + await assertCanvasPainted(page, 'completed first-camp scout visit'); + + const firstCampStatusTabPointAfterScout = await readCampTabControlPoint(page, 'status'); + await page.mouse.click( + firstCampStatusTabPointAfterScout.x, + firstCampStatusTabPointAfterScout.y + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp?.()?.activeTab === 'status', + undefined, + { timeout: 30000 } + ); + const firstCampDialogueTabPoint = await readCampTabControlPoint(page, 'dialogue'); await page.mouse.move(firstCampDialogueTabPoint.x, firstCampDialogueTabPoint.y); await page.waitForTimeout(80); @@ -3987,7 +4114,18 @@ try { await openCampSortiePrep(page); await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-prep.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp sortie prep'); - const firstSortieBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const firstSortieBriefingProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + return { + state: window.__HEROS_DEBUG__?.camp?.(), + scoutSummaryText: scene?.sortieBriefingScoutSummaryText?.text ?? null, + scoutSummaryVisible: Boolean( + scene?.sortieBriefingScoutSummaryText?.active && + scene.sortieBriefingScoutSummaryText.visible + ) + }; + }); + const firstSortieBriefingState = firstSortieBriefingProbe.state; assert( firstSortieBriefingState?.stagedSortiePrep === true && firstSortieBriefingState?.sortiePrepStep === 'briefing' && @@ -4011,9 +4149,21 @@ try { boundsInside( firstSortieBriefingState.firstVictoryFollowup.ctaBounds, firstSortieBriefingState.firstVictoryFollowup.handoffCardBounds - ), + ), `Expected the first sortie to open at the staged battlefield briefing: ${JSON.stringify(firstSortieBriefingState)}` ); + assert( + firstSortieBriefingState?.firstPursuitScoutMemory?.completed === true && + firstSortieBriefingState.firstPursuitScoutMemory.choiceId === firstPursuitScoutChoiceId && + firstSortieBriefingState.firstPursuitScoutMemory.activeForNextSortie === true && + firstSortieBriefingState.firstPursuitScoutMemory.targetBattleId === 'second-battle-yellow-turban-pursuit' && + firstSortieBriefingState.firstPursuitScoutMemory.deploymentIntentPreview === true && + firstSortieBriefingState.firstPursuitScoutMemory.campSummaryLine === firstPursuitScoutCampSummaryLine && + firstSortieBriefingProbe.scoutSummaryText === `${firstPursuitScoutCampSummaryLine} · 배치 단계 적 의도 선공개` && + firstSortieBriefingProbe.scoutSummaryVisible === true && + boundsWithinFhdViewport(firstSortieBriefingState.firstPursuitScoutMemory.briefingSummaryBounds), + `Expected sortie preparation to visibly explain the selected scout payoff before launch: ${JSON.stringify(firstSortieBriefingProbe)}` + ); const firstFollowupOrderCtaPoint = await readFirstVictoryFollowupControlPoint(page, 'cta'); await page.mouse.click(firstFollowupOrderCtaPoint.x, firstFollowupOrderCtaPoint.y); await page.waitForFunction(() => { @@ -4644,6 +4794,57 @@ try { await advanceSortiePrepStep(page, 'loadout'); await clickLegacyUi(page, 1116, 656); await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit', { startDeployment: false }); + await page.waitForFunction( + ({ visitId, choiceId }) => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + return ( + battle?.battleId === 'second-battle-yellow-turban-pursuit' && + battle?.phase === 'deployment' && + battle?.firstPursuitScoutMemory?.active === true && + battle.firstPursuitScoutMemory.visitId === visitId && + battle.firstPursuitScoutMemory.choiceId === choiceId && + battle.firstPursuitScoutMemory.forecastVisibleDuringDeployment === true && + battle?.counterplayFeedback?.forecastVisible === true && + battle?.enemyIntentPreviews?.length > 0 && + battle?.enemyIntentVisualCount > 0 + ); + }, + { visitId: firstPursuitScoutVisitId, choiceId: firstPursuitScoutChoiceId }, + { timeout: 30000 } + ); + const secondBattleScoutDeployment = await readBattleDoctrineProbe(page); + assert( + secondBattleScoutDeployment?.battleId === 'second-battle-yellow-turban-pursuit' && + secondBattleScoutDeployment.phase === 'deployment' && + secondBattleScoutDeployment.activeBattleEvent === null && + !secondBattleScoutDeployment.triggeredBattleEvents?.includes('opening') && + secondBattleScoutDeployment.firstPursuitScoutMemory?.active === true && + secondBattleScoutDeployment.firstPursuitScoutMemory.sourceBattleId === 'first-battle-zhuo-commandery' && + secondBattleScoutDeployment.firstPursuitScoutMemory.targetBattleId === 'second-battle-yellow-turban-pursuit' && + secondBattleScoutDeployment.firstPursuitScoutMemory.visitId === firstPursuitScoutVisitId && + secondBattleScoutDeployment.firstPursuitScoutMemory.choiceId === firstPursuitScoutChoiceId && + secondBattleScoutDeployment.firstPursuitScoutMemory.choiceLabel === firstPursuitScoutChoiceLabel && + secondBattleScoutDeployment.firstPursuitScoutMemory.deploymentIntentPreview === true && + secondBattleScoutDeployment.firstPursuitScoutMemory.forecastVisibleDuringDeployment === true && + secondBattleScoutDeployment.firstPursuitScoutMemory.forecastCount === secondBattleScoutDeployment.enemyIntentPreviews.length && + secondBattleScoutDeployment.firstPursuitScoutMemory.forecastCount > 0 && + secondBattleScoutDeployment.firstPursuitScoutMemory.visualCount === secondBattleScoutDeployment.enemyIntentVisualCount && + secondBattleScoutDeployment.firstPursuitScoutMemory.visualCount > 0 && + secondBattleScoutDeployment.firstPursuitScoutMemory.campSummaryLine === firstPursuitScoutCampSummaryLine && + sameJsonValue(secondBattleScoutDeployment.firstPursuitScoutMemory.openingLines, [ + firstPursuitScoutCampSummaryLine, + firstPursuitScoutOpeningGuide + ]) && + secondBattleScoutDeployment.counterplayFeedback?.forecastVisible === true && + secondBattleScoutDeployment.battleHud?.deploymentPanel?.subtitleText?.includes(firstPursuitScoutCampSummaryLine) && + secondBattleScoutDeployment.battleHud.deploymentPanel.subtitleText.includes('첫 군령') && + secondBattleScoutDeployment.openingBannerTexts?.length === 0, + `Expected the selected camp reconnaissance to reveal enemy intent during second-battle deployment, before opening starts: ${JSON.stringify(secondBattleScoutDeployment)}` + ); + await assertBaselineBrowserViewport(page, 'second-battle scout deployment'); + await page.screenshot({ path: `${screenshotDir}/rc-second-battle-scout-intel-deployment.png`, fullPage: true }); + await assertCanvasPainted(page, 'second-battle scout deployment'); + await clickBattleDeploymentStart(page); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); @@ -4658,6 +4859,28 @@ try { const secondBattleProbe = await readBattleDoctrineProbe(page); assert(secondBattleProbe?.battleId === 'second-battle-yellow-turban-pursuit', `Expected first camp sortie to enter second battle: ${JSON.stringify(secondBattleProbe)}`); + assert( + secondBattleProbe?.activeBattleEvent?.key === 'opening' && + secondBattleProbe.firstPursuitScoutMemory?.active === true && + secondBattleProbe.firstPursuitScoutMemory.choiceId === firstPursuitScoutChoiceId && + secondBattleProbe.activeBattleEvent.lines.filter( + (line) => line === firstPursuitScoutCampSummaryLine + ).length === 1 && + secondBattleProbe.activeBattleEvent.lines.filter( + (line) => line === firstPursuitScoutOpeningGuide + ).length === 1 && + secondBattleProbe.openingBannerTexts?.filter( + (text) => text === firstPursuitScoutCampSummaryLine + ).length === 1 && + secondBattleProbe.openingBannerTexts?.filter( + (text) => text === firstPursuitScoutOpeningGuide + ).length === 1, + `Expected the second-battle opening event and visible doctrine banner to include the selected camp preparation exactly once: ${JSON.stringify({ + memory: secondBattleProbe?.firstPursuitScoutMemory, + event: secondBattleProbe?.activeBattleEvent, + bannerTexts: secondBattleProbe?.openingBannerTexts + })}` + ); assert( secondBattleProbe?.sortieOperationOrder?.selectedId === 'mobile' && secondBattleProbe.sortieOperationOrder.result === null, `Expected the explicitly accepted mobile follow-up order to reach the next battle unchanged: ${JSON.stringify(secondBattleProbe?.sortieOperationOrder)}` @@ -5092,6 +5315,61 @@ try { `Expected the victory report to close the remembered-pair feedback loop with the applied 23% rate, +5%p source, attempts, successes, and damage: ${JSON.stringify(secondBattleCoreResult)}` ); + const restartedThirdBattleForScoutIsolation = await page.evaluate((battleId) => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if (!scene) { + return false; + } + scene.scene.restart({ battleId }); + return true; + }, 'third-battle-guangzong-road'); + assert(restartedThirdBattleForScoutIsolation, 'Expected the active battle scene to restart into the third battle for scout-memory isolation.'); + await waitForBattleReady(page, 'third-battle-guangzong-road', false); + const thirdBattleScoutDeployment = await readBattleDoctrineProbe(page); + assert( + thirdBattleScoutDeployment?.battleId === 'third-battle-guangzong-road' && + thirdBattleScoutDeployment.phase === 'deployment' && + thirdBattleScoutDeployment.firstPursuitScoutMemory?.active === false && + thirdBattleScoutDeployment.firstPursuitScoutMemory.targetBattleId === 'second-battle-yellow-turban-pursuit' && + thirdBattleScoutDeployment.firstPursuitScoutMemory.deploymentIntentPreview === false && + thirdBattleScoutDeployment.firstPursuitScoutMemory.forecastVisibleDuringDeployment === false && + thirdBattleScoutDeployment.firstPursuitScoutMemory.campSummaryLine === null && + thirdBattleScoutDeployment.firstPursuitScoutMemory.openingLines?.length === 0 && + thirdBattleScoutDeployment.counterplayFeedback?.forecastVisible === false && + thirdBattleScoutDeployment.enemyIntentPreviews?.length === 0 && + thirdBattleScoutDeployment.enemyIntentVisualCount === 0 && + !thirdBattleScoutDeployment.battleHud?.deploymentPanel?.subtitleText?.includes(firstPursuitScoutCampSummaryLine), + `Expected valid first-camp scout memory to stop before third-battle deployment: ${JSON.stringify(thirdBattleScoutDeployment)}` + ); + await clickBattleDeploymentStart(page); + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + return ( + battle?.battleId === 'third-battle-guangzong-road' && + battle?.phase === 'idle' && + battle?.activeBattleEvent?.key === 'opening' + ); + }, undefined, { timeout: 90000 }); + const thirdBattleScoutOpening = await readBattleDoctrineProbe(page); + const thirdBattleOpeningTexts = [ + ...(thirdBattleScoutOpening?.activeBattleEvent?.lines ?? []), + ...(thirdBattleScoutOpening?.openingBannerTexts ?? []) + ]; + assert( + thirdBattleScoutOpening?.firstPursuitScoutMemory?.active === false && + thirdBattleOpeningTexts.every((text) => ( + text !== firstPursuitScoutCampSummaryLine && + text !== firstPursuitScoutOpeningGuide && + !text.includes('강가 매복로 정찰 완료') && + !text.includes('나루 앞 숲과 궁병 엄호선') + )), + `Expected the choice-specific camp preparation to remain absent from the third-battle opening: ${JSON.stringify({ + memory: thirdBattleScoutOpening?.firstPursuitScoutMemory, + event: thirdBattleScoutOpening?.activeBattleEvent, + bannerTexts: thirdBattleScoutOpening?.openingBannerTexts + })}` + ); + const requiredCasualtySaveBefore = await readCampaignSave(page); try { await seedCampaignSave(page, { @@ -10185,7 +10463,7 @@ async function openCampSortiePrep(page) { ); const point = rewardVisible ? await readCampVictoryRewardControlPoint(page, 'sortie') - : await readCampStaticTextControlPoint(page, '다음 이야기'); + : await readCampStaticTextControlPoint(page, '출진 준비'); await page.mouse.click(point.x, point.y); await waitForSortiePrep(page); } @@ -10453,6 +10731,21 @@ async function assertCanvasPainted(page, context) { assert(probe.distinctColorBuckets >= 12, `Expected visually varied canvas pixels during ${context}: ${JSON.stringify(probe)}`); } +async function assertBaselineBrowserViewport(page, context) { + const viewport = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + devicePixelRatio: window.devicePixelRatio + })); + assert( + viewport.width === baselineViewport.width && + viewport.height === baselineViewport.height && + viewport.devicePixelRatio === desktopBrowserDeviceScaleFactor, + `Expected ${context} at the explicit 1920x1080 DPR1 baseline: ${JSON.stringify(viewport)}` + ); + return viewport; +} + async function readCampaignSave(page) { return page.evaluate(() => { const read = (key) => { @@ -10692,6 +10985,73 @@ async function readCampTabControlPoint(page, tab) { return probe; } +async function readCampVisitChoiceControlPoint(page, visitId, choiceId) { + const probe = await page.evaluate(({ visitId, choiceId }) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp?.(); + const canvas = document.querySelector('canvas'); + const visit = scene?.availableCampVisits?.().find((candidate) => candidate.id === visitId); + const choice = visit?.choices?.find((candidate) => candidate.id === choiceId); + const candidates = (scene?.contentObjects ?? []) + .filter((object) => ( + object?.type === 'Text' && + choice?.label && + object.text?.startsWith(choice.label) && + object.active && + object.visible && + object.input?.enabled + )) + .sort((left, right) => left.y - right.y || left.x - right.x); + const target = candidates[0]; + if ( + !scene || + !canvas || + state?.activeTab !== 'visit' || + state?.selectedVisitId !== visitId || + !choice || + !target + ) { + return { + ready: false, + visitId, + choiceId, + activeTab: state?.activeTab ?? null, + selectedVisitId: state?.selectedVisitId ?? null, + availableVisitIds: state?.availableVisitIds ?? [], + availableChoiceIds: visit?.choices?.map((candidate) => candidate.id) ?? [], + candidateCount: candidates.length + }; + } + + const bounds = target.getBounds(); + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + visitId, + choiceId, + choiceLabel: choice.label, + renderedLabel: target.text, + candidateCount: candidates.length, + bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, { visitId, choiceId }); + assert( + probe.ready === true && + probe.choiceLabel === firstPursuitScoutChoiceLabel && + probe.renderedLabel?.startsWith(firstPursuitScoutChoiceLabel) && + probe.candidateCount === 1 && + Number.isFinite(probe.x) && + Number.isFinite(probe.y) && + boundsWithinFhdViewport(probe.bounds), + `Expected one visible interactive choice ${choiceId} for camp visit ${visitId}: ${JSON.stringify(probe)}` + ); + return probe; +} + async function readFirstVictoryFollowupControlPoint(page, control, choiceId) { const probe = await page.evaluate(({ control, choiceId }) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 727e9ec..9ab1b0d 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -25,6 +25,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-pursuit-scout-memory.mjs', 'scripts/verify-prologue-exploration-asset-data.mjs', 'scripts/verify-exploration-character-asset-data.mjs', 'scripts/verify-prologue-dialogue-portrait-data.mjs', diff --git a/src/game/data/firstPursuitScoutMemory.ts b/src/game/data/firstPursuitScoutMemory.ts new file mode 100644 index 0000000..f769e72 --- /dev/null +++ b/src/game/data/firstPursuitScoutMemory.ts @@ -0,0 +1,106 @@ +import type { BattleScenarioId } from './battles'; +import type { CampaignState } from '../state/campaignState'; + +export const firstPursuitScoutVisitId = 'first-pursuit-scout-tent'; +export const firstPursuitScoutSourceBattleId = + 'first-battle-zhuo-commandery'; +export const firstPursuitScoutTargetBattleId = + 'second-battle-yellow-turban-pursuit'; +export const firstPursuitScoutChoiceIds = [ + 'trace-river-ambush', + 'mark-village-relief' +] as const; + +export type FirstPursuitScoutChoiceId = + (typeof firstPursuitScoutChoiceIds)[number]; + +type FirstPursuitScoutChoiceMemory = { + choiceLabel: string; + campSummaryLine: string; + openingLines: readonly [string, string]; +}; + +export type FirstPursuitScoutMemory = { + sourceBattleId: typeof firstPursuitScoutSourceBattleId; + targetBattleId: typeof firstPursuitScoutTargetBattleId; + visitId: typeof firstPursuitScoutVisitId; + choiceId: FirstPursuitScoutChoiceId; + choiceLabel: string; + deploymentIntentPreview: true; + campSummaryLine: string; + openingLines: [string, string]; +}; + +export type ResolveFirstPursuitScoutMemoryOptions = { + campaign: CampaignState; + battleId: BattleScenarioId; +}; + +const firstPursuitScoutChoiceMemoryById = { + 'trace-river-ambush': { + choiceLabel: '강가 매복 흔적을 쫓는다', + campSummaryLine: '군영 준비 · 강가 매복로 정찰 완료', + openingLines: [ + '군영 준비 · 강가 매복로 정찰 완료', + '정찰 인계 · 나루 앞 숲과 궁병 엄호선을 먼저 확인하십시오.' + ] + }, + 'mark-village-relief': { + choiceLabel: '마을 구호 길을 표시한다', + campSummaryLine: '군영 준비 · 북쪽 마을 구호로 표식 완료', + openingLines: [ + '군영 준비 · 북쪽 마을 구호로 표식 완료', + '정찰 인계 · 마을 진입로와 피난민 퇴로를 우선 확보하십시오.' + ] + } +} as const satisfies Record< + FirstPursuitScoutChoiceId, + FirstPursuitScoutChoiceMemory +>; + +export function resolveFirstPursuitScoutMemory({ + campaign, + battleId +}: ResolveFirstPursuitScoutMemoryOptions): FirstPursuitScoutMemory | undefined { + if (battleId !== firstPursuitScoutTargetBattleId) { + return undefined; + } + + const sourceSettlement = + campaign.battleHistory[firstPursuitScoutSourceBattleId]; + if ( + !sourceSettlement || + sourceSettlement.battleId !== firstPursuitScoutSourceBattleId || + sourceSettlement.outcome !== 'victory' || + !campaign.completedCampVisits.includes(firstPursuitScoutVisitId) + ) { + return undefined; + } + + const choiceId = + campaign.campVisitChoiceIds[firstPursuitScoutVisitId]; + if (!isFirstPursuitScoutChoiceId(choiceId)) { + return undefined; + } + + const choiceMemory = firstPursuitScoutChoiceMemoryById[choiceId]; + return { + sourceBattleId: firstPursuitScoutSourceBattleId, + targetBattleId: firstPursuitScoutTargetBattleId, + visitId: firstPursuitScoutVisitId, + choiceId, + choiceLabel: choiceMemory.choiceLabel, + deploymentIntentPreview: true, + campSummaryLine: choiceMemory.campSummaryLine, + openingLines: [...choiceMemory.openingLines] + }; +} + +export function isFirstPursuitScoutChoiceId( + value: unknown +): value is FirstPursuitScoutChoiceId { + return ( + typeof value === 'string' && + (firstPursuitScoutChoiceIds as readonly string[]).includes(value) + ); +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index deb93ea..e2b284f 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -124,6 +124,10 @@ import { firstBattleCamaraderieBonusFor, selectDominantSortieCooperationSnapshot } from '../data/firstBattleCamaraderieMemory'; +import { + resolveFirstPursuitScoutMemory, + type FirstPursuitScoutMemory +} from '../data/firstPursuitScoutMemory'; import { evaluateSortieOrder, evaluateSortieOrderProgress, @@ -3893,6 +3897,7 @@ export class BattleScene extends Phaser.Scene { private launchSortieResonanceBondId?: string; private launchSortieRecommendation?: CampaignSortieRecommendationSnapshot; private firstBattlePreparation: FirstBattlePreparationState = { ...emptyFirstBattlePreparationState }; + private firstPursuitScoutMemory?: FirstPursuitScoutMemory; private prologueVolunteerReassured = false; private coreResonancePursuitAttempts = 0; private coreResonancePursuitSuccesses = 0; @@ -4001,6 +4006,10 @@ export class BattleScene extends Phaser.Scene { this.firstBattleTutorialSkipButton = undefined; const campaign = getCampaignState(); this.firstBattlePreparation = this.resolveFirstBattlePreparation(campaign); + this.firstPursuitScoutMemory = resolveFirstPursuitScoutMemory({ + campaign, + battleId: battleScenario.id + }); this.prologueVolunteerReassured = campaign.completedTutorialIds.includes( prologueMilitiaCampCampaignTutorialIds.reassureVolunteer ); @@ -8582,6 +8591,9 @@ export class BattleScene extends Phaser.Scene { if (scoutBrief) { return `${scoutBrief}. 적 표식을 보며 시작 위치를 조정하세요.`; } + if (this.firstPursuitScoutMemory) { + return `${this.firstPursuitScoutMemory.campSummaryLine}. 간옹이 표시한 적의 첫 움직임을 보며 전열을 조정하세요.`; + } return '추천 전열이 적용되어 있습니다. 장수를 선택한 뒤 시작 구역 안에서 위치를 바꿀 수 있습니다.'; } @@ -8605,6 +8617,15 @@ export class BattleScene extends Phaser.Scene { })(); return `${preparationSummary}\n${strategySummary}`; } + if (this.firstPursuitScoutMemory) { + const strategySummary = this.launchSortieRecommendation + ? `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}` + : (() => { + const order = sortieOrderDefinition(this.launchSortieOrderId); + return `첫 군령 · ${order.label} · ${order.summary}`; + })(); + return `${this.firstPursuitScoutMemory.campSummaryLine}\n${strategySummary}`; + } if (this.launchSortieRecommendation) { return `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`; } @@ -9651,8 +9672,13 @@ export class BattleScene extends Phaser.Scene { private enemyIntentForecastEnabled() { if ( this.phase === 'deployment' && - this.firstBattlePreparation.eligible && - this.firstBattlePreparation.scouting && + ( + ( + this.firstBattlePreparation.eligible && + this.firstBattlePreparation.scouting + ) || + this.firstPursuitScoutMemory?.deploymentIntentPreview === true + ) && !this.battleOutcome ) { return true; @@ -17840,6 +17866,7 @@ export class BattleScene extends Phaser.Scene { ...(this.launchSortieRecommendation ? [`선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`] : []), + ...(this.firstPursuitScoutMemory?.openingLines ?? []), ...cityInformationLines, enemyIntentOpeningGuide(this.isFirstPursuitRoleEffectBattle() ? tacticalInitiativeThreshold : undefined), ...(battleScenario.id === 'first-battle-zhuo-commandery' @@ -18154,9 +18181,12 @@ export class BattleScene extends Phaser.Scene { private showSortieDoctrineBanner(event: BattleSavePendingEvent) { const { title, lines } = event; const completedCityInformationLines = new Set(this.completedCityInformationBriefingLines()); + const firstPursuitScoutLines = new Set(this.firstPursuitScoutMemory?.openingLines ?? []); const doctrineLines = lines.filter((line) => ( completedCityInformationLines.has(line) || + firstPursuitScoutLines.has(line) || line.startsWith('도원 공명') || + line.startsWith('핵심 공명') || line.startsWith('공명 ') || line.startsWith('적 의도') || line.startsWith('첫 행동') @@ -19386,6 +19416,10 @@ export class BattleScene extends Phaser.Scene { this.launchSortieResonanceBondId = this.validatedLaunchSortieResonanceBondId( this.launchSortieResonanceBondId ); + this.firstPursuitScoutMemory = resolveFirstPursuitScoutMemory({ + campaign: getCampaignState(), + battleId: battleScenario.id + }); this.refreshLaunchCamaraderieMemoryBonus(getCampaignState()); if (this.launchSortieResonanceBondId) { this.restoreCoreResonancePursuitStats(state.coreResonanceStats); @@ -28956,6 +28990,36 @@ export class BattleScene extends Phaser.Scene { }; } + private firstPursuitScoutMemoryDebugState() { + const memory = this.firstPursuitScoutMemory; + if (!memory) { + return { + active: false, + targetBattleId: 'second-battle-yellow-turban-pursuit', + deploymentIntentPreview: false, + forecastVisibleDuringDeployment: false, + campSummaryLine: null, + openingLines: [] + }; + } + return { + active: true, + sourceBattleId: memory.sourceBattleId, + targetBattleId: memory.targetBattleId, + visitId: memory.visitId, + choiceId: memory.choiceId, + choiceLabel: memory.choiceLabel, + deploymentIntentPreview: memory.deploymentIntentPreview, + forecastVisibleDuringDeployment: + this.phase === 'deployment' && + this.enemyIntentForecastEnabled(), + forecastCount: this.enemyIntentForecasts.size, + visualCount: this.enemyIntentVisuals.length, + campSummaryLine: memory.campSummaryLine, + openingLines: [...memory.openingLines] + }; + } + private firstBattleVolunteerPromiseDebugState() { const line = volunteerPromiseLineForBattle( battleScenario.id, @@ -29105,6 +29169,7 @@ export class BattleScene extends Phaser.Scene { : null, battleHud: this.battleHudDebugState(), firstBattlePreparation: this.firstBattlePreparationDebugState(), + firstPursuitScoutMemory: this.firstPursuitScoutMemoryDebugState(), firstBattleNarrativeMemory: { volunteerPromise: this.firstBattleVolunteerPromiseDebugState() }, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index e94227b..ba2608d 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -34,6 +34,10 @@ import { resolveFirstBattleCamaraderieMemory, type FirstBattleCamaraderieMemory } from '../data/firstBattleCamaraderieMemory'; +import { + firstPursuitScoutVisitId, + resolveFirstPursuitScoutMemory +} from '../data/firstPursuitScoutMemory'; import { findCityStayAfterBattle, type CityEquipmentOffer, @@ -8396,6 +8400,35 @@ const campDialogues: CampDialogue[] = [ ]; const campVisits: CampVisitDefinition[] = [ + { + id: 'first-pursuit-scout-tent', + title: '북문 추격로 정찰', + location: '탁현 북문 정찰막', + availableAfterBattleIds: [campBattleIds.first], + bondId: 'liu-bei__jian-yong', + description: '간옹과 황건 잔당의 두 갈래 길을 살피고 정찰 기록을 출진 준비에 올립니다.', + lines: [ + '간옹: 발자국이 강가와 마을길로 갈렸습니다.', + '유비: 매복도 찾고 백성의 길도 지켜야 하오.', + '정찰 지도에 두 길을 나란히 표시했습니다.' + ], + choices: [ + { + id: 'trace-river-ambush', + label: '강가 매복 흔적을 쫓는다', + response: '간옹: 강변의 꺾인 갈대와 버려진 횃불을 표시했습니다. 상처약도 챙겨 이 기록을 출진 준비에 올리겠습니다.', + bondExp: 10, + itemRewards: ['상처약 1'] + }, + { + id: 'mark-village-relief', + label: '마을 구호 길을 표시한다', + response: '간옹: 피난민이 돌아올 샛길과 군량을 나눌 곳을 적었습니다. 콩 한 자루와 이 기록을 다음 전투 준비에 보태겠습니다.', + bondExp: 10, + itemRewards: ['콩 1'] + } + ] + }, { id: 'jingzhou-scholar-rumors', title: '형주 선비들의 모임', @@ -11419,6 +11452,7 @@ export class CampScene extends Phaser.Scene { private sortiePrimaryActionButton?: CampActionButtonView; private sortieNextActionGuideBackground?: Phaser.GameObjects.Rectangle; private sortieNextActionGuideText?: Phaser.GameObjects.Text; + private sortieBriefingScoutSummaryText?: Phaser.GameObjects.Text; private firstBattleFollowupCtaButton?: Phaser.GameObjects.Rectangle; private firstBattleFollowupCard?: Phaser.GameObjects.Rectangle; private firstBattleFollowupSummaryText?: Phaser.GameObjects.Text; @@ -12824,6 +12858,14 @@ export class CampScene extends Phaser.Scene { return resolveFirstBattleCamaraderieMemory(this.campaign); } + private firstPursuitScoutMemoryForNextSortie() { + const campaign = this.campaign; + const scenario = this.nextSortieScenario(); + return campaign && scenario + ? resolveFirstPursuitScoutMemory({ campaign, battleId: scenario.id }) + : undefined; + } + private firstBattleCamaraderieDialogue(): CampDialogue | undefined { if (!this.isFirstSortiePrepSlice()) { return undefined; @@ -12926,7 +12968,7 @@ export class CampScene extends Phaser.Scene { this.showCampSaveSlotPanel(); }); const flow = this.currentSortieFlow(); - const storyButtonLabel = this.isFinalEpilogueFlow(flow) && this.campaign?.step === flow.campaignStep ? '엔딩 보기' : '다음 이야기'; + const storyButtonLabel = this.isFinalEpilogueFlow(flow) && this.campaign?.step === flow.campaignStep ? '엔딩 보기' : '출진 준비'; this.addCommandButton(storyButtonLabel, 1152, 38, 142, () => { soundDirector.playSelect(); if (this.isFinalEpilogueFlow(flow)) { @@ -16248,13 +16290,25 @@ export class CampScene extends Phaser.Scene { const objectiveLabel = hasBattle ? '목표' : '진행'; const terrainLabel = hasBattle ? '지형' : '장소'; const recommendationLabel = hasBattle ? '추천' : '동행'; + const scoutMemory = this.firstPursuitScoutMemoryForNextSortie(); this.trackSortie(this.add.text(x + 18, y + 12, briefing.eyebrow, this.textStyle(15, '#9fb0bf', true))).setDepth(depth + 1); this.trackSortie(this.add.text(x + 18, y + 34, briefing.title, this.textStyle(22, '#f2e3bf', true))).setDepth(depth + 1); + if (scoutMemory) { + this.sortieBriefingScoutSummaryText = this.trackSortie( + this.add.text( + x + 18, + y + 64, + `${scoutMemory.campSummaryLine} · 배치 단계 적 의도 선공개`, + this.textStyle(12, '#a8ffd0', true) + ) + ); + this.sortieBriefingScoutSummaryText.setDepth(depth + 1); + } this.trackSortie( - this.add.text(x + 18, y + 65, briefing.description, { - ...this.textStyle(14, '#d4dce6'), + this.add.text(x + 18, y + (scoutMemory ? 84 : 65), briefing.description, { + ...this.textStyle(scoutMemory ? 12 : 14, '#d4dce6'), wordWrap: { width: width - 36, useAdvancedWrap: true }, - lineSpacing: 3 + lineSpacing: scoutMemory ? 1 : 3 }) ).setDepth(depth + 1); const chipY = y + height - 46; @@ -20416,6 +20470,7 @@ export class CampScene extends Phaser.Scene { this.sortiePrimaryActionButton = undefined; this.sortieNextActionGuideBackground = undefined; this.sortieNextActionGuideText = undefined; + this.sortieBriefingScoutSummaryText = undefined; this.firstBattleFollowupCtaButton = undefined; this.firstBattleFollowupCard = undefined; this.firstBattleFollowupSummaryText = undefined; @@ -23225,7 +23280,7 @@ export class CampScene extends Phaser.Scene { bg.setStrokeStyle(1, palette.gold, 0.58); const locationLabel = this.currentCityStay()?.city.name ?? this.campSkinSelection?.skin.locationLabel ?? '현지'; this.track(this.add.text(x + 24, y + 22, `${locationLabel} 방문`, this.textStyle(24, '#f2e3bf', true))); - this.track(this.add.text(x + 24, y + 56, '군영 밖 사람들을 만나 정보, 보급, 공명 보상을 얻습니다. 각 방문은 한 번만 완료할 수 있습니다.', this.textStyle(14, '#d4dce6'))); + this.track(this.add.text(x + 24, y + 56, '획득한 정보·보급·공명은 출진 준비와 다음 전투로 이어집니다. 방문은 한 번만 완료됩니다.', this.textStyle(14, '#d4dce6'))); const visits = this.availableCampVisits(); if (!visits.some((visit) => visit.id === this.selectedVisitId)) { @@ -23334,7 +23389,7 @@ export class CampScene extends Phaser.Scene { const bg = this.track(this.add.rectangle(x, y, width, 104, 0x0d141c, 0.9)); bg.setOrigin(0); bg.setStrokeStyle(1, palette.blue, 0.42); - this.track(this.add.text(x + 14, y + 12, '현지 준비', this.textStyle(17, '#f2e3bf', true))); + this.track(this.add.text(x + 14, y + 12, '현지 준비 · 다음 전투 반영', this.textStyle(17, '#f2e3bf', true))); this.track(this.add.text(x + 14, y + 40, `방문 ${completed.length}/${visits.length} 완료`, this.textStyle(13, completed.length >= visits.length && visits.length > 0 ? '#a8ffd0' : '#d4dce6', true))); const isWolongSearch = this.currentSortieFlow().nextBattleId === campBattleIds.seventeenth; const informationLabel = isWolongSearch @@ -25005,6 +25060,10 @@ export class CampScene extends Phaser.Scene { const firstBattleCampFollowup = this.firstBattleCampFollowup(); const firstBattleCamaraderieMemory = this.firstBattleCamaraderieMemory(); const firstBattleCamaraderieDialogue = this.firstBattleCamaraderieDialogue(); + const firstPursuitScoutVisit = this.availableCampVisits().find( + (visit) => visit.id === firstPursuitScoutVisitId + ); + const firstPursuitScoutMemory = this.firstPursuitScoutMemoryForNextSortie(); const availableCampDialogues = this.availableCampDialogues(); const selectedCampDialogue = availableCampDialogues.find( (dialogue) => dialogue.id === this.selectedDialogueId @@ -25164,6 +25223,25 @@ export class CampScene extends Phaser.Scene { } } : null, + firstPursuitScoutMemory: { + visitId: firstPursuitScoutVisitId, + available: Boolean(firstPursuitScoutVisit), + completed: this.completedCampVisits().includes(firstPursuitScoutVisitId), + choiceId: this.campaign?.campVisitChoiceIds[firstPursuitScoutVisitId] ?? null, + choices: firstPursuitScoutVisit?.choices.map((choice) => ({ + id: choice.id, + label: choice.label, + response: choice.response, + rewardText: this.visitRewardText(choice) + })) ?? [], + activeForNextSortie: Boolean(firstPursuitScoutMemory), + targetBattleId: firstPursuitScoutMemory?.targetBattleId ?? null, + choiceLabel: firstPursuitScoutMemory?.choiceLabel ?? null, + deploymentIntentPreview: firstPursuitScoutMemory?.deploymentIntentPreview ?? false, + campSummaryLine: firstPursuitScoutMemory?.campSummaryLine ?? null, + openingLines: firstPursuitScoutMemory ? [...firstPursuitScoutMemory.openingLines] : [], + briefingSummaryBounds: this.sortieTextBoundsDebug(this.sortieBriefingScoutSummaryText) + }, selectedDialogue: selectedCampDialogue ? { id: selectedCampDialogue.id,