diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index 8641f40..5462f9a 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -113,9 +113,24 @@ RPG로 다듬는다. - 세 군령의 달성·미달 여섯 조합, 선택 대화의 저장·재개, 첫 교전 중복 방지, 이후 전투 누출 방지와 1920x1080 배치를 자동 검증한다. -다음 초반 묶음은 첫 승리에서 드러난 군령의 남은 과제가 첫 군영의 동료 -대화와 다음 편성 제안으로 이어지게 해, 회고가 설명으로 끝나지 않고 다음 -준비 행동을 바꾸도록 연결하는 것을 우선한다. +### 완료한 4차 묶음: 회고를 다음 준비 행동으로 + +- 첫 승리에서 선택한 군령, 달성 여부와 첫 미달 조건을 첫 군영에서도 같은 + 저장 기록으로 다시 파생한다. +- 첫 군영의 대화 탭과 관련 동료 대화 한 곳에만 `회고` 표식을 보여 주며, + 정적 대화 ID·선택지·공명 보상은 유지한다. +- 관련 대화는 군령별 인물 조합과 실제 결과에 맞는 짧은 세 줄로 바꾸되, + 오래되거나 불완전한 저장은 기존 대사를 그대로 사용한다. +- 첫 추격전 브리핑에서 회고의 한 가지 보완 또는 확장 과제를 보여 주고 + `추천 군령 보기`로 해당 작전 명령 카드를 강조한다. +- 추천은 군령을 자동 선택하지 않는다. 플레이어가 조건과 보상을 다시 + 확인하고 직접 선택한 순간에만 기존 저장 흐름으로 반영한다. +- 미달했다면 같은 군령의 첫 미달 조건을 보완하고, 완수했다면 잔당 + 추격전의 성격에 맞는 기동 군령을 다음 학습 과제로 제안한다. + +다음 초반 묶음은 전투에서 실제로 함께 협공·엄호·회복한 사건을 동료가 +짧게 기억하고, 거점의 공명 대화와 다음 전투의 작은 협동 효과로 연결하는 +것을 우선 검토한다. ## 장기 개선 순서 @@ -169,3 +184,11 @@ RPG로 다듬는다. 전략층과 전술층이 서로 의미를 만드는 이중 구조 - [Into the Breach 디자인 회고, GDC](https://www.gdcvault.com/play/1025772/-Into-the-Breach-Design): 명확한 정보와 제한된 규칙으로 깊은 전술 선택 만들기 +- [Nintendo: Fire Emblem Three Houses 101](https://www.nintendo.com/au/news-and-articles/fire-emblem-three-houses-101/): + 자유 탐색·관계 대화와 출전 인원·장비·정찰을 한 전투 준비 순환으로 연결 +- [Nintendo: Fire Emblem Engage 전투와 유대](https://www.nintendo.com/ph/switch/ayfn/system/battle.html): + 함께 싸우고 회복한 경험을 거점 대화와 다음 전투의 인접 지원으로 회수 +- [Darkest Dungeon Affliction System, Game Developer](https://www.gamedeveloper.com/design/game-design-deep-dive-i-darkest-dungeon-s-i-affliction-system): + 원정의 결과가 귀환 뒤 인물별 준비 선택으로 이어지고 수치 결과는 명확히 공개 +- [Into the Breach Design Postmortem PDF](https://media.gdcvault.com/gdc2019/presentations/Into%20the%20Breach%20Postmortem%20Final.pdf): + 모호한 전략층을 명확한 보상, 미리 계획 가능한 선택과 단일 화면으로 압축 diff --git a/package.json b/package.json index 1f75340..03fe703 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "verify:city-stays": "node scripts/verify-city-stay-data.mjs", "verify:city-stays:browser": "node scripts/verify-city-stay-browser.mjs", "verify:prologue-village": "node scripts/verify-prologue-village-data.mjs", + "verify:first-battle-camp-followup": "node scripts/verify-first-battle-camp-followup.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-battle-camp-followup.mjs b/scripts/verify-first-battle-camp-followup.mjs new file mode 100644 index 0000000..63acd00 --- /dev/null +++ b/scripts/verify-first-battle-camp-followup.mjs @@ -0,0 +1,334 @@ +import { createServer } from 'vite'; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const followup = await server.ssrLoadModule( + '/src/game/data/firstBattleCampFollowup.ts' + ); + const narrative = await server.ssrLoadModule( + '/src/game/data/firstBattleNarrativeMemory.ts' + ); + const orders = await server.ssrLoadModule('/src/game/data/sortieOrders.ts'); + const failures = []; + + const missedCases = [ + { + orderId: 'elite', + criterionId: 'elite-grade', + criterionLabel: '편성 평가 A 이상', + mentorUnitId: 'liu-bei', + mentorName: '유비', + targetDialogueId: 'liu-guan-after-first-battle' + }, + { + orderId: 'elite', + criterionId: 'elite-survival', + criterionLabel: '출전 전원 생존', + mentorUnitId: 'guan-yu', + mentorName: '관우', + targetDialogueId: 'liu-guan-after-first-battle' + }, + { + orderId: 'mobile', + criterionId: 'mobile-quick', + criterionLabel: '제한 턴 이내 승리', + mentorUnitId: 'zhang-fei', + mentorName: '장비', + targetDialogueId: 'liu-zhang-after-first-battle' + }, + { + orderId: 'mobile', + criterionId: 'mobile-breakthrough', + criterionLabel: '돌파 기여 1회', + mentorUnitId: 'zhang-fei', + mentorName: '장비', + targetDialogueId: 'liu-zhang-after-first-battle' + }, + { + orderId: 'siege', + criterionId: 'siege-objective', + criterionLabel: '거점·보너스 목표 달성', + mentorUnitId: 'guan-yu', + mentorName: '관우', + targetDialogueId: 'guan-zhang-after-first-battle' + }, + { + orderId: 'siege', + criterionId: 'siege-front', + criterionLabel: '전열 기여 1 이상', + mentorUnitId: 'guan-yu', + mentorName: '관우', + targetDialogueId: 'guan-zhang-after-first-battle' + }, + { + orderId: 'siege', + criterionId: 'siege-support', + criterionLabel: '후원 기여 1 이상', + mentorUnitId: 'guan-yu', + mentorName: '관우', + targetDialogueId: 'guan-zhang-after-first-battle' + } + ]; + + for (const testCase of missedCases) { + const report = makeReport( + narrative.firstBattleNarrativeBattleId, + orders.sortieOrderCheckIdsByOrder, + testCase.orderId, + false, + [testCase.criterionId] + ); + const snapshot = JSON.stringify(report); + deepFreeze(report); + const result = followup.resolveFirstBattleCampFollowup(report); + const label = `${testCase.orderId}/${testCase.criterionId}`; + + assert(result.source === 'campaign', `${label}: campaign result required`, failures); + if (result.source !== 'campaign') { + continue; + } + assert(result.fallbackReason === null, `${label}: fallback reason must be null`, failures); + assert(result.originalOrderId === testCase.orderId, `${label}: original order`, failures); + assert(result.achieved === false, `${label}: missed status`, failures); + assert(result.focusCriterionId === testCase.criterionId, `${label}: focus criterion`, failures); + assert(result.focusCriterionLabel === testCase.criterionLabel, `${label}: focus label`, failures); + assert(result.mentorUnitId === testCase.mentorUnitId, `${label}: mentor unit`, failures); + assert(result.mentorName === testCase.mentorName, `${label}: mentor name`, failures); + assert( + result.targetDialogueId === testCase.targetDialogueId, + `${label}: dialogue mapping`, + failures + ); + assert( + result.recommendedNextOrderId === testCase.orderId, + `${label}: missed order must be retried`, + failures + ); + assert( + result.recommendationReason.includes(testCase.criterionLabel), + `${label}: recommendation must name missed criterion`, + failures + ); + verifyPresentation(result, label, failures); + assert(JSON.stringify(report) === snapshot, `${label}: source report must remain immutable`, failures); + } + + const expectedDialogueByOrder = { + elite: 'liu-guan-after-first-battle', + mobile: 'liu-zhang-after-first-battle', + siege: 'guan-zhang-after-first-battle' + }; + for (const orderId of orders.sortieOrderIds) { + const report = makeReport( + narrative.firstBattleNarrativeBattleId, + orders.sortieOrderCheckIdsByOrder, + orderId, + true, + [] + ); + const result = followup.resolveFirstBattleCampFollowup(report); + const label = `${orderId}/achieved`; + + assert(result.source === 'campaign', `${label}: campaign result required`, failures); + if (result.source !== 'campaign') { + continue; + } + assert(result.achieved === true, `${label}: achieved status`, failures); + assert( + result.recommendedNextOrderId === 'mobile', + `${label}: pursuit context must recommend mobile`, + failures + ); + assert( + result.focusCriterionId === 'mobile-quick', + `${label}: pursuit focus must be turn limit`, + failures + ); + assert( + result.targetDialogueId === expectedDialogueByOrder[orderId], + `${label}: original order dialogue mapping`, + failures + ); + assert( + result.recommendationReason.includes('잔당 추격전'), + `${label}: recommendation must explain the next battle context`, + failures + ); + verifyPresentation(result, label, failures); + } + + const firstMissedReport = makeReport( + narrative.firstBattleNarrativeBattleId, + orders.sortieOrderCheckIdsByOrder, + 'siege', + false, + ['siege-objective', 'siege-support'] + ); + const firstMissed = followup.resolveFirstBattleCampFollowup(firstMissedReport); + assert( + firstMissed.source === 'campaign' && + firstMissed.focusCriterionId === 'siege-objective', + 'multiple misses must focus the first authored criterion', + failures + ); + + const malformedCases = [ + { + label: 'missing report', + report: undefined, + reason: 'missing-report' + }, + { + label: 'old save without sortie order', + report: { + battleId: narrative.firstBattleNarrativeBattleId, + outcome: 'victory' + }, + reason: 'missing-order' + }, + { + label: 'different battle', + report: { + battleId: 'second-battle-yellow-turban-pursuit', + outcome: 'victory' + }, + reason: 'battle-mismatch' + }, + { + label: 'defeat', + report: { + battleId: narrative.firstBattleNarrativeBattleId, + outcome: 'defeat' + }, + reason: 'non-victory' + }, + { + label: 'missing progress criterion', + report: { + battleId: narrative.firstBattleNarrativeBattleId, + outcome: 'victory', + sortieOrder: { + orderId: 'elite', + achieved: false, + progress: [{ id: 'victory', achieved: true }] + } + }, + reason: 'invalid-order-progress' + }, + { + label: 'inconsistent achieved flag', + report: makeReport( + narrative.firstBattleNarrativeBattleId, + orders.sortieOrderCheckIdsByOrder, + 'mobile', + true, + ['mobile-quick'] + ), + reason: 'invalid-order-progress' + }, + { + label: 'inconsistent missed flag', + report: makeReport( + narrative.firstBattleNarrativeBattleId, + orders.sortieOrderCheckIdsByOrder, + 'siege', + false, + [] + ), + reason: 'invalid-order-progress' + } + ]; + + for (const testCase of malformedCases) { + const snapshot = JSON.stringify(testCase.report); + if (testCase.report) { + deepFreeze(testCase.report); + } + const result = followup.resolveFirstBattleCampFollowup(testCase.report); + assert(result.source === 'fallback', `${testCase.label}: fallback required`, failures); + assert( + result.source === 'fallback' && result.fallbackReason === testCase.reason, + `${testCase.label}: expected ${testCase.reason}`, + failures + ); + assert( + JSON.stringify(testCase.report) === snapshot, + `${testCase.label}: fallback must not mutate report`, + failures + ); + } + + if (failures.length > 0) { + throw new Error( + `First-battle camp follow-up verification failed:\n- ${failures.join('\n- ')}` + ); + } + console.log( + `First-battle camp follow-up verification passed (${missedCases.length} missed mappings, ${orders.sortieOrderIds.length} achieved pursuit mappings).` + ); +} finally { + await server.close(); +} + +function makeReport( + battleId, + checkIdsByOrder, + orderId, + achieved, + missedCriterionIds +) { + return { + battleId, + outcome: 'victory', + turnNumber: 6, + mvp: { unitId: 'guan-yu', name: '관우' }, + sortieOrder: { + orderId, + achieved, + progress: checkIdsByOrder[orderId].map((id) => ({ + id, + achieved: id === 'victory' || !missedCriterionIds.includes(id), + value: id === 'victory' || !missedCriterionIds.includes(id) ? 1 : 0, + target: 1 + })) + } + }; +} + +function verifyPresentation(result, label, failures) { + assert(result.dialogueTitle.trim().length >= 4, `${label}: dialogue title`, failures); + assert(result.dialogueLines.length === 3, `${label}: exactly three dialogue lines`, failures); + assert( + result.dialogueLines.every((line) => line.includes(':') && line.length <= 72), + `${label}: concise speaker-labelled dialogue`, + failures + ); + assert( + result.dialogueLines[0].startsWith(`${result.mentorName}:`), + `${label}: mentor must open the dialogue`, + failures + ); + assert(result.recommendationTitle.trim().length >= 4, `${label}: recommendation title`, failures); + assert(result.recommendationReason.trim().length >= 12, `${label}: recommendation reason`, failures); + assert(result.statusLabel.trim().length >= 4, `${label}: status label`, failures); +} + +function assert(condition, message, failures) { + if (!condition) { + failures.push(message); + } +} + +function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value; + } + Object.freeze(value); + Object.values(value).forEach(deepFreeze); + return value; +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index cc8395b..b6c40f0 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -2667,6 +2667,138 @@ try { `Expected camp tab hover styling to reset on pointerout: ${JSON.stringify(firstCampTabResetState?.campTabs)}` ); + await page.mouse.click(firstCampDialogueTabPoint.x, firstCampDialogueTabPoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.activeTab === 'dialogue', + undefined, + { timeout: 30000 } + ); + const firstVictoryDialogueProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp(); + const followup = state?.firstVictoryFollowup; + const renderedDialogueText = followup?.dialogueLines?.join('\n') ?? null; + const activeTexts = (scene?.contentObjects ?? []).filter( + (object) => object?.type === 'Text' && object.active && object.visible + ); + const boundsFor = (object) => { + const bounds = object?.getBounds?.(); + return bounds + ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } + : null; + }; + return { + state, + dialogueTabBadgeText: + scene?.tabButtons?.find((button) => button.tab === 'dialogue')?.newBadge?.text ?? null, + rowMarkers: activeTexts + .filter((object) => object.text === '회고') + .map((object) => ({ text: object.text, bounds: boundsFor(object) })), + renderedDialogueBlocks: renderedDialogueText + ? activeTexts + .filter((object) => object.text === renderedDialogueText) + .map((object) => ({ text: object.text, bounds: boundsFor(object) })) + : [] + }; + }); + const firstVictoryFollowup = firstVictoryDialogueProbe.state?.firstVictoryFollowup; + const firstVictorySelectedDialogue = firstVictoryDialogueProbe.state?.selectedDialogue; + assert( + firstVictoryDialogueProbe.state?.activeTab === 'dialogue' && + firstVictoryDialogueProbe.state?.campTabs?.find((tab) => tab.id === 'dialogue')?.newBadgeVisible === true && + firstVictoryDialogueProbe.dialogueTabBadgeText === '회고' && + firstVictoryFollowup?.available === true && + firstVictoryFollowup.sourceBattleId === persistedFirstReport?.battleId && + firstVictoryFollowup.originalOrderId === persistedFirstReport?.sortieOrder?.orderId && + firstVictoryFollowup.originalOrderId === 'elite' && + firstVictoryFollowup.achieved === persistedFirstReport?.sortieOrder?.achieved && + firstVictoryFollowup.achieved === true && + firstVictoryFollowup.targetDialogueId === 'liu-guan-after-first-battle' && + firstVictoryFollowup.recommendedOrderId === 'mobile' && + firstVictoryFollowup.dialogueLines?.length === 3 && + firstVictoryFollowup.dialogueLines.some((line) => line.includes('정예 군령')) && + firstVictoryFollowup.dialogueLines.some((line) => line.includes('빠르게 추격')) && + firstVictorySelectedDialogue?.id === firstVictoryFollowup.targetDialogueId && + firstVictorySelectedDialogue.firstVictoryFollowup === true && + sameJsonValue(firstVictorySelectedDialogue.unitIds, ['liu-bei', 'guan-yu']) && + sameJsonValue(firstVictorySelectedDialogue.lines, firstVictoryFollowup.dialogueLines) && + firstVictoryDialogueProbe.renderedDialogueBlocks.length === 1 && + firstVictoryDialogueProbe.rowMarkers.length === 1 && + boundsInside( + firstVictoryDialogueProbe.rowMarkers[0]?.bounds, + firstVictoryFollowup.dialogueRowBounds + ) && + firstVictorySelectedDialogue.choices?.length === 2 && + isFiniteBounds(firstVictorySelectedDialogue.bodyBounds) && + firstVictorySelectedDialogue.bodyBounds.y + firstVictorySelectedDialogue.bodyBounds.height <= + firstVictorySelectedDialogue.choices[0]?.bounds?.y - 8 && + firstVictorySelectedDialogue.choices.every((choice) => boundsWithinFhdViewport(choice.bounds)), + `Expected the first-camp dialogue tab to render one marked Liu Bei/Guan Yu follow-up from the persisted elite victory: ${JSON.stringify(firstVictoryDialogueProbe)}` + ); + await page.screenshot({ + path: `${screenshotDir}/rc-first-camp-victory-followup-dialogue.png`, + fullPage: true + }); + await assertCanvasPainted(page, 'first camp victory follow-up dialogue'); + const firstVictoryChoice = firstVictorySelectedDialogue.choices[0]; + const firstVictoryChoicePoint = await readFirstVictoryFollowupControlPoint( + page, + 'choice', + firstVictoryChoice.id + ); + await page.mouse.click(firstVictoryChoicePoint.x, firstVictoryChoicePoint.y); + await page.waitForFunction( + ({ dialogueId, choiceId }) => { + const camp = window.__HEROS_DEBUG__?.camp(); + return ( + camp?.firstVictoryFollowup?.dialogueCompleted === true && + camp?.selectedDialogue?.completed === true && + camp?.campaign?.campDialogueChoiceIds?.[dialogueId] === choiceId + ); + }, + { + dialogueId: firstVictoryFollowup.targetDialogueId, + choiceId: firstVictoryChoice.id + }, + { timeout: 30000 } + ); + const completedFirstVictoryDialogueProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + return { + state: window.__HEROS_DEBUG__?.camp(), + remainingRowMarkerCount: (scene?.contentObjects ?? []).filter( + (object) => object?.type === 'Text' && object.active && object.visible && object.text === '회고' + ).length + }; + }); + const completedFirstVictoryDialogueSave = await readCampaignSave(page); + const firstVictoryDialoguePersisted = (save) => ( + save?.completedCampDialogues?.includes(firstVictoryFollowup.targetDialogueId) && + save?.firstBattleReport?.completedCampDialogues?.includes(firstVictoryFollowup.targetDialogueId) && + save?.campDialogueChoiceIds?.[firstVictoryFollowup.targetDialogueId] === firstVictoryChoice.id + ); + assert( + completedFirstVictoryDialogueProbe.state?.firstVictoryFollowup?.dialogueCompleted === true && + completedFirstVictoryDialogueProbe.state?.selectedDialogue?.completed === true && + completedFirstVictoryDialogueProbe.state?.completedAvailableDialogues?.includes( + firstVictoryFollowup.targetDialogueId + ) && + completedFirstVictoryDialogueProbe.state?.campaign?.campDialogueChoiceIds?.[ + firstVictoryFollowup.targetDialogueId + ] === firstVictoryChoice.id && + completedFirstVictoryDialogueProbe.state?.campTabs?.find( + (tab) => tab.id === 'dialogue' + )?.newBadgeVisible === false && + completedFirstVictoryDialogueProbe.remainingRowMarkerCount === 0 && + firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.current) && + firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.slot1), + `Expected the actual first-victory choice to persist in current and slot saves and clear both follow-up markers: ${JSON.stringify({ + probe: completedFirstVictoryDialogueProbe, + save: completedFirstVictoryDialogueSave, + choiceId: firstVictoryChoice.id + })}` + ); + const firstCampEvaluation = firstCampProbe.state?.reportFormationEvaluation; const firstCampHistory = firstCampProbe.state?.reportFormationHistory; const firstCampEvaluationSaveBefore = await readCampaignSave(page); @@ -3459,12 +3591,86 @@ try { firstSortieBriefingState?.sortiePrimaryAction?.lineWidth >= 2 && firstSortieBriefingState?.sortieNextActionGuide?.label?.includes('무장 편성으로 이동') && boundsWithinFhdViewport(firstSortieBriefingState?.sortieNextActionGuide?.bounds) && - boundsWithinFhdViewport(firstSortieBriefingState?.sortiePrimaryAction?.bounds), + boundsWithinFhdViewport(firstSortieBriefingState?.sortiePrimaryAction?.bounds) && + firstSortieBriefingState?.firstVictoryFollowup?.originalOrderId === 'elite' && + firstSortieBriefingState.firstVictoryFollowup.achieved === true && + firstSortieBriefingState.firstVictoryFollowup.recommendedOrderId === 'mobile' && + firstSortieBriefingState.firstVictoryFollowup.ctaInteractive === true && + boundsWithinFhdViewport(firstSortieBriefingState.firstVictoryFollowup.ctaBounds) && + boundsInside( + firstSortieBriefingState.firstVictoryFollowup.handoffSummaryBounds, + firstSortieBriefingState.firstVictoryFollowup.handoffCardBounds + ) && + boundsInside( + firstSortieBriefingState.firstVictoryFollowup.ctaBounds, + firstSortieBriefingState.firstVictoryFollowup.handoffCardBounds + ), `Expected the first sortie to open at the staged battlefield briefing: ${JSON.stringify(firstSortieBriefingState)}` ); - await advanceSortiePrepStep(page, 'formation'); + const firstFollowupOrderCtaPoint = await readFirstVictoryFollowupControlPoint(page, 'cta'); + await page.mouse.click(firstFollowupOrderCtaPoint.x, firstFollowupOrderCtaPoint.y); + await page.waitForFunction(() => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return ( + camp?.sortiePrepStep === 'formation' && + camp?.sortieOperationOrder?.open === true + ); + }, undefined, { timeout: 30000 }); + const firstFollowupOrderState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const firstFollowupRecommendedCards = firstFollowupOrderState?.sortieOperationOrder?.cards?.filter( + (card) => card.recommended + ) ?? []; + const firstFollowupMobileCard = firstFollowupOrderState?.sortieOperationOrder?.cards?.find( + (card) => card.id === 'mobile' + ); + assert( + firstFollowupOrderState?.sortiePrepStep === 'formation' && + firstFollowupOrderState?.sortieOperationOrder?.open === true && + firstFollowupOrderState.sortieOperationOrder.formationPanelMode === 'order' && + firstFollowupOrderState.sortieOperationOrder.recommendedId === 'mobile' && + firstFollowupOrderState.sortieOperationOrder.selectedId === null && + firstFollowupOrderState.sortieOperationOrder.complete === false && + firstFollowupRecommendedCards.length === 1 && + firstFollowupRecommendedCards[0]?.id === 'mobile' && + firstFollowupMobileCard?.recommended === true && + firstFollowupMobileCard.selected === false && + boundsWithinFhdViewport(firstFollowupMobileCard.cardBounds) && + firstFollowupOrderState?.firstVictoryFollowup?.recommendedOrderId === 'mobile' && + firstFollowupOrderState.firstVictoryFollowup.recommendedOrderSelected === false && + sameJsonValue( + firstFollowupOrderState.firstVictoryFollowup.recommendedOrderCardBounds, + firstFollowupMobileCard.cardBounds + ), + `Expected the briefing follow-up to open three unselected order choices with only mobile highlighted: ${JSON.stringify(firstFollowupOrderState?.sortieOperationOrder)}` + ); await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-formation.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp sortie formation'); + const firstFollowupMobileCardPoint = await readSortieOrderControlPoint(page, 'card', 'mobile'); + await page.mouse.click(firstFollowupMobileCardPoint.x, firstFollowupMobileCardPoint.y); + await page.waitForFunction(() => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return ( + camp?.sortieOperationOrder?.selectedId === 'mobile' && + camp?.sortieOperationOrder?.complete === true + ); + }, undefined, { timeout: 30000 }); + const selectedFirstFollowupOrderSave = await readCampaignSave(page); + const firstFollowupOrderPersisted = (save) => ( + save?.sortieOrderSelection?.battleId === 'second-battle-yellow-turban-pursuit' && + save?.sortieOrderSelection?.orderId === 'mobile' + ); + assert( + firstFollowupOrderPersisted(selectedFirstFollowupOrderSave.current) && + firstFollowupOrderPersisted(selectedFirstFollowupOrderSave.slot1), + `Expected the highlighted mobile order to persist for the second battle in current and slot saves: ${JSON.stringify(selectedFirstFollowupOrderSave)}` + ); + const firstFollowupOrderClosePoint = await readSortieOrderControlPoint(page, 'close'); + await page.mouse.click(firstFollowupOrderClosePoint.x, firstFollowupOrderClosePoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp?.()?.sortieOperationOrder?.open === false, + undefined, + { timeout: 30000 } + ); const firstSortieFormationState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const firstSortieSynergy = firstSortieFormationState?.sortieSynergyPreview; assert( @@ -3476,7 +3682,11 @@ try { firstSortieSynergy?.coveredRoleCount === 3 && firstSortieSynergy?.strongestBond?.damageBonus === 9 && firstSortieSynergy?.strongestBond?.chainRate === 18 && - firstSortieSynergy?.firstPursuitTrinityConfigured === true, + firstSortieSynergy?.firstPursuitTrinityConfigured === true && + firstSortieFormationState?.sortieOperationOrder?.open === false && + firstSortieFormationState.sortieOperationOrder.selectedId === 'mobile' && + firstSortieFormationState.sortieOperationOrder.complete === true && + firstSortieFormationState?.firstVictoryFollowup?.recommendedOrderSelected === true, `Expected first sortie formation to expose its next action and preview three bonds, the strongest effect, and trinity: ${JSON.stringify(firstSortieFormationState)}` ); @@ -3915,12 +4125,12 @@ 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?.sortieOperationOrder?.selectedId === 'elite' && secondBattleProbe.sortieOperationOrder.result === null, - `Expected the explicitly declared elite order to reach the next battle unchanged: ${JSON.stringify(secondBattleProbe?.sortieOperationOrder)}` + 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)}` ); await assertLiveSortieOrderHud(page, { battleId: 'second-battle-yellow-turban-pursuit', - orderId: 'elite', + orderId: 'mobile', context: 'second battle' }); assertSameMembers( @@ -8574,7 +8784,21 @@ async function assertLiveSortieOrderHud(page, { battleId, orderId, context }) { }, { expectedBattleId: battleId, expectedOrderId: orderId }); const hud = probe.hud; - const requiredCriteriaIds = ['elite-grade', 'elite-survival']; + const orderExpectation = { + elite: { + label: '정예', + criteriaIds: ['elite-grade', 'elite-survival'] + }, + mobile: { + label: '기동', + criteriaIds: ['mobile-quick', 'mobile-breakthrough'] + }, + siege: { + label: '공성', + criteriaIds: ['siege-objective', 'siege-front', 'siege-support'] + } + }[orderId]; + const requiredCriteriaIds = orderExpectation?.criteriaIds ?? []; const criterionIds = hud?.criteria?.map((entry) => entry.id) ?? []; assert( probe.battleId === battleId && @@ -8588,7 +8812,7 @@ async function assertLiveSortieOrderHud(page, { battleId, orderId, context }) { assert( hud?.visible === true && hud.orderId === orderId && - typeof hud.label === 'string' && hud.label.includes('정예') && + typeof hud.label === 'string' && hud.label.includes(orderExpectation?.label ?? '') && typeof hud.victoryLabel === 'string' && hud.victoryLabel.length > 0 && typeof hud.victoryTone === 'string' && hud.victoryTone.length > 0 && typeof hud.status === 'string' && hud.status.length > 0 && @@ -9824,13 +10048,18 @@ async function readCampStaticTextControlPoint(page, label) { async function readCampTabControlPoint(page, tab) { const probe = await page.evaluate((targetTab) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp?.(); const canvas = document.querySelector('canvas'); - const button = scene?.tabButtons?.find((candidate) => candidate.tab === targetTab); - const target = button?.bg; - if (!scene || !canvas || !target?.active || !target.visible || !target.input?.enabled) { - return { ready: false, tab: targetTab, availableTabs: scene?.tabButtons?.map((candidate) => candidate.tab) ?? [] }; + const tab = state?.campTabs?.find((candidate) => candidate.id === targetTab); + const bounds = tab?.bounds; + if (!scene || !canvas || !tab?.interactive || !bounds) { + return { + ready: false, + tab: targetTab, + availableTabs: state?.campTabs?.map((candidate) => candidate.id) ?? [], + bounds: bounds ?? null + }; } - const bounds = target.getBounds(); const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; @@ -9849,6 +10078,49 @@ async function readCampTabControlPoint(page, tab) { return probe; } +async function readFirstVictoryFollowupControlPoint(page, control, choiceId) { + const probe = await page.evaluate(({ control, choiceId }) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp?.(); + const canvas = document.querySelector('canvas'); + const followup = state?.firstVictoryFollowup; + const choice = state?.selectedDialogue?.choices?.find((candidate) => candidate.id === choiceId); + const bounds = control === 'cta' ? followup?.ctaBounds : choice?.bounds; + const interactive = control === 'cta' + ? followup?.ctaInteractive === true + : Boolean(choice?.bounds); + if (!scene || !canvas || !interactive || !bounds) { + return { + ready: false, + control, + choiceId: choiceId ?? null, + bounds: bounds ?? null, + followup: followup ?? null, + availableChoiceIds: state?.selectedDialogue?.choices?.map((candidate) => candidate.id) ?? [] + }; + } + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + control, + choiceId: choiceId ?? null, + bounds, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, { control, choiceId }); + assert( + probe.ready === true && + Number.isFinite(probe.x) && + Number.isFinite(probe.y) && + boundsWithinFhdViewport(probe.bounds), + `Expected an interactive first-victory ${control} control from debug bounds: ${JSON.stringify(probe)}` + ); + return probe; +} + async function readSortieTextControlPoint(page, label) { const probe = await page.evaluate((targetLabel) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 4ab3f17..bcdbe80 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -23,6 +23,7 @@ const checks = [ 'scripts/verify-camp-reward-data.mjs', 'scripts/verify-city-stay-data.mjs', 'scripts/verify-prologue-village-data.mjs', + 'scripts/verify-first-battle-camp-followup.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/firstBattleCampFollowup.ts b/src/game/data/firstBattleCampFollowup.ts new file mode 100644 index 0000000..45b53bc --- /dev/null +++ b/src/game/data/firstBattleCampFollowup.ts @@ -0,0 +1,257 @@ +import { + resolveFirstBattleVictoryNarrative, + type FirstBattleNarrativeReport, + type FirstBattleVictoryNarrativeFallbackReason +} from './firstBattleNarrativeMemory'; +import { + sortieOrderDefinition, + type CampaignSortieOrderId, + type CampaignSortieOrderProgressId +} from './sortieOrders'; + +export type FirstBattleCampMentorUnitId = 'liu-bei' | 'guan-yu' | 'zhang-fei'; +export type FirstBattleCampFocusCriterionId = Exclude< + CampaignSortieOrderProgressId, + 'victory' +>; + +export type FirstBattleCampFollowup = { + source: 'campaign'; + fallbackReason: null; + originalOrderId: CampaignSortieOrderId; + originalOrderLabel: string; + achieved: boolean; + focusCriterionId: FirstBattleCampFocusCriterionId; + focusCriterionLabel: string; + mentorUnitId: FirstBattleCampMentorUnitId; + mentorName: string; + targetDialogueId: + | 'liu-guan-after-first-battle' + | 'liu-zhang-after-first-battle' + | 'guan-zhang-after-first-battle'; + dialogueTitle: string; + dialogueLines: [string, string, string]; + recommendedNextOrderId: CampaignSortieOrderId; + recommendedNextOrderLabel: string; + recommendationTitle: string; + recommendationReason: string; + statusLabel: string; +}; + +export type FirstBattleCampFollowupFallback = { + source: 'fallback'; + fallbackReason: FirstBattleVictoryNarrativeFallbackReason; +}; + +export type FirstBattleCampFollowupResolution = + | FirstBattleCampFollowup + | FirstBattleCampFollowupFallback; + +type FollowupVoice = Pick< + FirstBattleCampFollowup, + | 'mentorUnitId' + | 'mentorName' + | 'dialogueTitle' + | 'dialogueLines' +>; + +const firstBattleCriterionLabels: Record< + FirstBattleCampFocusCriterionId, + string +> = { + 'elite-grade': '편성 평가 A 이상', + 'elite-survival': '출전 전원 생존', + 'mobile-quick': '제한 턴 이내 승리', + 'mobile-breakthrough': '돌파 기여 1회', + 'siege-objective': '거점·보너스 목표 달성', + 'siege-front': '전열 기여 1 이상', + 'siege-support': '후원 기여 1 이상' +}; + +const targetDialogueIdByOrder: Record< + CampaignSortieOrderId, + FirstBattleCampFollowup['targetDialogueId'] +> = { + elite: 'liu-guan-after-first-battle', + mobile: 'liu-zhang-after-first-battle', + siege: 'guan-zhang-after-first-battle' +}; + +const missedCriterionVoices: Record< + FirstBattleCampFocusCriterionId, + FollowupVoice +> = { + 'elite-grade': { + mentorUnitId: 'liu-bei', + mentorName: '유비', + dialogueTitle: '승리의 모양을 다듬다', + dialogueLines: [ + '유비: 승리는 거두었지만 병력과 역할의 짜임은 더 다듬어야 하오.', + '관우: 다음에는 각자의 자리를 먼저 정하고 서로의 빈틈을 메우겠습니다.', + '유비: 같은 군령으로 편성 평가부터 바로잡아 봅시다.' + ] + }, + 'elite-survival': { + mentorUnitId: 'guan-yu', + mentorName: '관우', + dialogueTitle: '함께 돌아오는 승리', + dialogueLines: [ + '관우: 이겼다 해도 한 사람을 두고 돌아오면 온전한 승리가 아닙니다.', + '유비: 앞선 자와 뒤처진 자를 함께 살피는 전열이 필요하겠소.', + '관우: 다음에는 제가 퇴로까지 지켜 모두 데려오겠습니다.' + ] + }, + 'mobile-quick': { + mentorUnitId: 'zhang-fei', + mentorName: '장비', + dialogueTitle: '한 걸음 빠른 결착', + dialogueLines: [ + '장비: 이겼지만 놈들에게 숨 돌릴 틈을 너무 오래 줬습니다.', + '유비: 빠름은 혼자 앞서는 것이 아니라 군 전체가 함께 닿는 것이오.', + '장비: 좋소! 다음에는 깃발을 보며 더 짧게 끝내겠습니다.' + ] + }, + 'mobile-breakthrough': { + mentorUnitId: 'zhang-fei', + mentorName: '장비', + dialogueTitle: '막힌 곳을 여는 창', + dialogueLines: [ + '장비: 발은 빨랐지만 정작 적진의 숨통을 끊는 한 방이 모자랐습니다.', + '유비: 측면이 길을 열 때 전열도 함께 움직일 수 있소.', + '장비: 다음에는 돌파 역할로 놈들의 빈틈부터 깨뜨리겠습니다.' + ] + }, + 'siege-objective': { + mentorUnitId: 'guan-yu', + mentorName: '관우', + dialogueTitle: '싸움 너머의 목적', + dialogueLines: [ + '관우: 적을 베는 데 몰두해 정작 지켜야 할 곳을 놓쳤다.', + '장비: 다음에는 창을 들기 전에 어디를 지킬지부터 보겠습니다.', + '관우: 좋다. 거점과 백성을 지키는 승리로 바로잡자.' + ] + }, + 'siege-front': { + mentorUnitId: 'guan-yu', + mentorName: '관우', + dialogueTitle: '흔들리지 않는 전열', + dialogueLines: [ + '관우: 앞줄이 버텨야 뒤의 계책과 도움이 제때 닿습니다.', + '장비: 강한 한 사람보다 서로 이어진 전열이 필요하다는 말씀이군요.', + '관우: 다음에는 내가 정면을 붙들 테니 네가 빈틈을 메워라.' + ] + }, + 'siege-support': { + mentorUnitId: 'guan-yu', + mentorName: '관우', + dialogueTitle: '뒤에서 완성하는 승리', + dialogueLines: [ + '관우: 앞의 공만 좇으면 뒤에서 살리고 돕는 손길을 놓치게 된다.', + '장비: 후원이 이어져야 저도 앞에서 더 오래 버틸 수 있겠군요.', + '관우: 다음에는 돕는 역할까지 우리 모두의 공으로 묶자.' + ] + } +}; + +const achievedOrderVoices: Record = { + elite: { + mentorUnitId: 'guan-yu', + mentorName: '관우', + dialogueTitle: '단단함 다음의 속도', + dialogueLines: [ + '관우: 정예 군령은 지켰으니, 잔당이 흩어지기 전에 발을 재촉해야 합니다.', + '유비: 좋소. 다만 모두의 보조가 맞아야 참된 기동이 되오.', + '관우: 다음에는 전열을 흐트러뜨리지 않고 빠르게 추격하겠습니다.' + ] + }, + mobile: { + mentorUnitId: 'zhang-fei', + mentorName: '장비', + dialogueTitle: '익힌 속도를 추격으로', + dialogueLines: [ + '장비: 빠르게 끝내는 법을 익혔으니 잔당 추격에도 딱 맞겠습니다!', + '유비: 익힌 속도를 유지하되 백성과 대열을 지나치지 마시오.', + '장비: 깃발이 보이는 거리에서 놈들의 퇴로를 끊겠습니다.' + ] + }, + siege: { + mentorUnitId: 'guan-yu', + mentorName: '관우', + dialogueTitle: '거점의 호흡을 추격으로', + dialogueLines: [ + '관우: 거점과 역할을 함께 지킨 호흡을 이제 빠른 추격으로 옮겨야 한다.', + '장비: 형님이 대열을 잡아 주면 제가 앞에서 퇴로를 끊겠습니다.', + '관우: 좋다. 흩어지지 않는 기동으로 잔당을 놓치지 말자.' + ] + } +}; + +export function resolveFirstBattleCampFollowup( + report?: FirstBattleNarrativeReport | null +): FirstBattleCampFollowupResolution { + const memory = resolveFirstBattleVictoryNarrative([], report).memory; + if (memory.source === 'fallback') { + return { + source: 'fallback', + fallbackReason: memory.fallbackReason ?? 'invalid-order-progress' + }; + } + + const orderId = memory.orderId; + const orderLabel = memory.orderLabel; + if (!orderId || !orderLabel || memory.achieved === null) { + return invalidOrderFallback(); + } + + const victoryProgress = memory.progress.find((entry) => entry.id === 'victory'); + const missedCriterionId = memory.missedCriterionIds.find( + (id): id is FirstBattleCampFocusCriterionId => id !== 'victory' + ); + const internallyConsistent = + victoryProgress?.achieved === true && + (memory.achieved ? !missedCriterionId : Boolean(missedCriterionId)); + if (!internallyConsistent) { + return invalidOrderFallback(); + } + + const recommendedNextOrderId = memory.achieved ? 'mobile' : orderId; + const focusCriterionId = memory.achieved + ? 'mobile-quick' + : missedCriterionId!; + const focusCriterionLabel = firstBattleCriterionLabels[focusCriterionId]; + const voice = memory.achieved + ? achievedOrderVoices[orderId] + : missedCriterionVoices[focusCriterionId]; + const recommendedNextOrderLabel = + sortieOrderDefinition(recommendedNextOrderId).label; + + return { + source: 'campaign', + fallbackReason: null, + originalOrderId: orderId, + originalOrderLabel: orderLabel, + achieved: memory.achieved, + focusCriterionId, + focusCriterionLabel, + ...voice, + targetDialogueId: targetDialogueIdByOrder[orderId], + recommendedNextOrderId, + recommendedNextOrderLabel, + recommendationTitle: memory.achieved + ? `${recommendedNextOrderLabel} 군령으로 잔당 추격` + : `${recommendedNextOrderLabel} 군령 재도전`, + recommendationReason: memory.achieved + ? `${orderLabel} 군령 완수. 다음 잔당 추격전에서는 ${recommendedNextOrderLabel} 군령으로 ${focusCriterionLabel}에 도전합니다.` + : `첫 승리에서 놓친 과제: ${focusCriterionLabel}. 같은 ${recommendedNextOrderLabel} 군령으로 다시 도전합니다.`, + statusLabel: memory.achieved + ? '완수 · 추격전 적용' + : `보완 필요 · ${focusCriterionLabel}` + }; +} + +function invalidOrderFallback(): FirstBattleCampFollowupFallback { + return { + source: 'fallback', + fallbackReason: 'invalid-order-progress' + }; +} diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 7d527ce..646a0e3 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -24,6 +24,10 @@ import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles'; import { getSortieFlow, type SortieFlow } from '../data/campaignFlow'; import { getCampSoundscape, type CampSoundscape } from '../data/campSoundscapes'; +import { + resolveFirstBattleCampFollowup, + type FirstBattleCampFollowup +} from '../data/firstBattleCampFollowup'; import { findCityStayAfterBattle, type CityEquipmentOffer, @@ -11353,6 +11357,9 @@ export class CampScene extends Phaser.Scene { private cityDialogueChoiceButtons: Phaser.GameObjects.Rectangle[] = []; private cityEquipmentOfferViews: CityEquipmentOfferView[] = []; private selectedDialogueId = campDialogues[0].id; + private campDialogueRowButtons: Record = {}; + private campDialogueChoiceButtons: Record = {}; + private campDialogueBodyText?: Phaser.GameObjects.Text; private selectedVisitId = campVisits[0].id; private equipmentInventoryPage = 0; private equipmentPanelBackground?: Phaser.GameObjects.Rectangle; @@ -11399,6 +11406,9 @@ export class CampScene extends Phaser.Scene { private sortiePrimaryActionButton?: CampActionButtonView; private sortieNextActionGuideBackground?: Phaser.GameObjects.Rectangle; private sortieNextActionGuideText?: Phaser.GameObjects.Text; + private firstBattleFollowupCtaButton?: Phaser.GameObjects.Rectangle; + private firstBattleFollowupCard?: Phaser.GameObjects.Rectangle; + private firstBattleFollowupSummaryText?: Phaser.GameObjects.Text; private visitedTabs = new Set(); private selectedSortieUnitIds: string[] = []; private sortieFormationAssignments: SortieFormationAssignments = {}; @@ -11471,6 +11481,9 @@ export class CampScene extends Phaser.Scene { this.cityInformationActionButton = undefined; this.cityDialogueChoiceButtons = []; this.cityEquipmentOfferViews = []; + this.campDialogueRowButtons = {}; + this.campDialogueChoiceButtons = {}; + this.campDialogueBodyText = undefined; this.dialogueObjects = []; this.sortieObjects = []; this.sortieComparisonPanelView = undefined; @@ -11535,6 +11548,9 @@ export class CampScene extends Phaser.Scene { this.sortiePrimaryActionButton = undefined; this.sortieNextActionGuideBackground = undefined; this.sortieNextActionGuideText = undefined; + this.firstBattleFollowupCtaButton = undefined; + this.firstBattleFollowupCard = undefined; + this.firstBattleFollowupSummaryText = undefined; this.visitedTabs = new Set(['status']); this.campaign = getCampaignState(); this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport(); @@ -12783,11 +12799,42 @@ export class CampScene extends Phaser.Scene { ); } + private firstBattleCampFollowup(): FirstBattleCampFollowup | undefined { + if (!this.isFirstSortiePrepSlice()) { + return undefined; + } + const resolution = resolveFirstBattleCampFollowup(this.report); + return resolution.source === 'campaign' ? resolution : undefined; + } + + private campDialogueWithFirstBattleFollowup(dialogue: CampDialogue) { + const followup = this.firstBattleCampFollowup(); + if (!followup || dialogue.id !== followup.targetDialogueId) { + return dialogue; + } + return { + ...dialogue, + title: followup.dialogueTitle, + lines: [...followup.dialogueLines] + }; + } + + private hasPendingFirstBattleCampFollowup() { + const followup = this.firstBattleCampFollowup(); + return Boolean( + followup && + !this.completedCampDialogues().includes(followup.targetDialogueId) + ); + } + private availableCampDialogues() { const battleId = this.currentCampBattleId(); const battleDialogues = campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(battleId)); const dialogues = this.filterCampEventsByStep(battleDialogues); - return dialogues.length > 0 ? dialogues : campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(defaultBattleScenario.id)); + const available = dialogues.length > 0 + ? dialogues + : campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(defaultBattleScenario.id)); + return available.map((dialogue) => this.campDialogueWithFirstBattleFollowup(dialogue)); } private completedAvailableDialogues() { @@ -13744,8 +13791,14 @@ export class CampScene extends Phaser.Scene { })); text.setOrigin(0.5); text.setInteractive({ useHandCursor: true }); - const badgeLabel = tab === 'city' ? '새' : width < 70 ? 'N' : 'NEW'; - const newBadge = tab === 'city' || tab === 'supplies' || tab === 'equipment' + const badgeLabel = tab === 'city' + ? '새' + : tab === 'dialogue' + ? '회고' + : width < 70 + ? 'N' + : 'NEW'; + const newBadge = tab === 'city' || tab === 'dialogue' || tab === 'supplies' || tab === 'equipment' ? this.scaleLegacyCampUi(this.add.text(x + width / 2 - 10, y - 17, badgeLabel, { ...this.textStyle(9, '#fff2b8', true), backgroundColor: '#8a3f25', @@ -13779,6 +13832,7 @@ export class CampScene extends Phaser.Scene { indicator.setAlpha(active ? 1 : 0.66); newBadge?.setVisible(Boolean( (tab === 'city' && this.hasPendingCityStayActions()) || + (tab === 'dialogue' && this.hasPendingFirstBattleCampFollowup()) || (tab === 'supplies' && pendingCategories.has('supplies')) || (tab === 'equipment' && pendingCategories.has('equipment')) )); @@ -14172,20 +14226,77 @@ export class CampScene extends Phaser.Scene { }); const commandY = y + height - 104; + const firstBattleFollowup = firstSortie ? this.firstBattleCampFollowup() : undefined; const command = this.trackSortie(this.add.rectangle(x + 18, commandY, width - 36, 86, 0x211b14, 0.94)); command.setOrigin(0); command.setDepth(depth + 1); command.setStrokeStyle(1, palette.gold, 0.6); - this.trackSortie(this.add.text(x + 34, commandY + 12, '지휘관 판단', this.textStyle(14, '#ffdf7b', true))).setDepth(depth + 2); - this.trackSortie( - this.add.text(x + 34, commandY + 36, scenario.tacticalGuide?.summary ?? scenario.openingObjectiveLines[1] ?? (firstSortie + if (firstBattleFollowup) { + this.firstBattleFollowupCard = command; + } + const commandTitle = firstBattleFollowup + ? `지난 전투 인계 · ${firstBattleFollowup.mentorName}` + : '지휘관 판단'; + this.trackSortie(this.add.text(x + 34, commandY + 12, commandTitle, this.textStyle(14, '#ffdf7b', true))).setDepth(depth + 2); + const commandSummary = firstBattleFollowup + ? firstBattleFollowup.achieved + ? `${firstBattleFollowup.originalOrderLabel} 완수 → ${firstBattleFollowup.recommendedNextOrderLabel} · ${firstBattleFollowup.focusCriterionLabel}` + : `${firstBattleFollowup.originalOrderLabel} 보완 → ${firstBattleFollowup.focusCriterionLabel}` + : scenario.tacticalGuide?.summary ?? scenario.openingObjectiveLines[1] ?? (firstSortie ? '세 형제가 서로 다른 길을 맡아 적의 전열을 흔드십시오.' - : '전열·돌파·후원의 균형으로 적의 주력을 나누십시오.'), { - ...this.textStyle(13, '#d4dce6'), - wordWrap: { width: width - 68, useAdvancedWrap: true }, - lineSpacing: 3 + : '전열·돌파·후원의 균형으로 적의 주력을 나누십시오.'); + const commandSummaryText = this.trackSortie( + this.add.text(x + 34, commandY + 36, commandSummary, { + ...this.textStyle(firstBattleFollowup ? 11 : 13, '#d4dce6'), + wordWrap: { width: firstBattleFollowup ? width - 214 : width - 68, useAdvancedWrap: true }, + lineSpacing: firstBattleFollowup ? 2 : 3 }) - ).setDepth(depth + 2); + ); + commandSummaryText.setDepth(depth + 2); + if (firstBattleFollowup) { + this.firstBattleFollowupSummaryText = commandSummaryText; + } + if (firstBattleFollowup) { + const buttonWidth = 132; + const buttonX = x + width - 86; + const buttonY = commandY + 51; + const button = this.trackSortie(this.add.rectangle(buttonX, buttonY, buttonWidth, 34, 0x4a371d, 0.98)); + button.setDepth(depth + 2); + button.setStrokeStyle(2, palette.gold, 0.92); + button.setInteractive({ useHandCursor: true }); + button.on('pointerover', () => button.setFillStyle(0x654c25, 1)); + button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98)); + button.on('pointerdown', () => this.openFirstBattleFollowupOrderBrowser()); + this.firstBattleFollowupCtaButton = button; + const label = this.trackSortie( + this.add.text( + buttonX, + buttonY, + `${firstBattleFollowup.recommendedNextOrderLabel} 군령 보기`, + this.textStyle(11, '#fff2b8', true) + ) + ); + label.setOrigin(0.5); + label.setDepth(depth + 3); + label.setInteractive({ useHandCursor: true }); + label.on('pointerover', () => button.setFillStyle(0x654c25, 1)); + label.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98)); + label.on('pointerdown', () => this.openFirstBattleFollowupOrderBrowser()); + } + } + + private openFirstBattleFollowupOrderBrowser() { + if (!this.firstBattleCampFollowup() || !this.nextSortieScenario()) { + return; + } + this.sortiePrepStep = 'formation'; + this.closeSortieRecommendationBrowserState(); + this.closeSortiePresetBrowserState(); + this.sortieFormationPanelMode = 'order'; + this.sortieHoveredUnitId = undefined; + this.sortiePinnedSwapCandidateUnitId = undefined; + soundDirector.playSelect(); + this.showSortiePrep(); } private renderFirstSortieArtwork(x: number, y: number, width: number, height: number, depth: number, alpha: number) { @@ -14627,8 +14738,12 @@ export class CampScene extends Phaser.Scene { bg.setDepth(depth); bg.setStrokeStyle(1, palette.gold, 0.7); + const firstBattleFollowup = this.firstBattleCampFollowup(); this.trackSortie(this.add.text(x + 18, y + 13, '작전 명령', this.textStyle(19, '#f2e3bf', true))).setDepth(depth + 1); - this.trackSortie(this.add.text(x + 126, y + 18, '편성과 별개 · 전투 능력 보너스 없음', this.textStyle(10, '#9fb0bf', true))) + const orderBrowserHint = firstBattleFollowup + ? `지난 전투 ${firstBattleFollowup.statusLabel} · ${firstBattleFollowup.recommendationTitle}` + : '편성과 별개 · 전투 능력 보너스 없음'; + this.trackSortie(this.add.text(x + 126, y + 18, this.compactText(orderBrowserHint, 48), this.textStyle(10, '#9fb0bf', true))) .setDepth(depth + 1); const closeButton = this.trackSortie(this.add.rectangle(x + width - 96, y + 9, 78, 30, 0x18232e, 0.96)); @@ -14655,17 +14770,19 @@ export class CampScene extends Phaser.Scene { sortieOrderDefinitions.forEach((definition, index) => { const cardY = y + 50 + index * (cardHeight + cardGap); const selected = selectedOrderId === definition.id; + const recommended = firstBattleFollowup?.recommendedNextOrderId === definition.id; const summary = summaries.get(definition.id); const currentResult = currentBattleResults?.[definition.id]; const accent = this.sortiePresetDefinition(definition.id).accent; - const fill = selected ? 0x1d3028 : currentResult?.achieved ? 0x172a22 : 0x151f2a; + const fill = selected ? 0x1d3028 : recommended ? 0x2a2419 : currentResult?.achieved ? 0x172a22 : 0x151f2a; + const stroke = selected ? palette.gold : recommended ? palette.gold : currentResult?.achieved ? palette.green : accent; const card = this.trackSortie(this.add.rectangle(cardX, cardY, cardWidth, cardHeight, fill, 0.98)); card.setOrigin(0); card.setDepth(depth + 1); - card.setStrokeStyle(selected ? 2 : 1, selected ? palette.gold : currentResult?.achieved ? palette.green : accent, selected ? 0.96 : 0.52); + card.setStrokeStyle(selected || recommended ? 2 : 1, stroke, selected ? 0.96 : recommended ? 0.84 : 0.52); card.setInteractive({ useHandCursor: true }); card.on('pointerover', () => card.setFillStyle(selected ? 0x263d32 : 0x22303c, 1).setStrokeStyle(2, palette.gold, 0.96)); - card.on('pointerout', () => card.setFillStyle(fill, 0.98).setStrokeStyle(selected ? 2 : 1, selected ? palette.gold : currentResult?.achieved ? palette.green : accent, selected ? 0.96 : 0.52)); + card.on('pointerout', () => card.setFillStyle(fill, 0.98).setStrokeStyle(selected || recommended ? 2 : 1, stroke, selected ? 0.96 : recommended ? 0.84 : 0.52)); card.on('pointerdown', () => this.selectSortieOrder(definition.id)); cards[definition.id] = card; @@ -14693,17 +14810,20 @@ export class CampScene extends Phaser.Scene { this.trackSortie(this.add.text(cardX + 22, cardY + 79, this.compactText(rewardLine, width > 650 ? 82 : 66), this.textStyle(10, '#ffdf7b', true))) .setDepth(depth + 2); - const badgeLabel = selected ? '선택됨' : currentResult?.achieved ? '달성 기록' : '선택'; - const badge = this.trackSortie(this.add.rectangle(cardX + cardWidth - 82, cardY + 9, 66, 22, selected ? 0x304333 : 0x18232e, 0.98)); + const badgeLabel = selected ? '선택됨' : recommended ? '회고 추천' : currentResult?.achieved ? '달성 기록' : '선택'; + const badge = this.trackSortie(this.add.rectangle(cardX + cardWidth - 82, cardY + 9, 66, 22, selected ? 0x304333 : recommended ? 0x4a371d : 0x18232e, 0.98)); badge.setOrigin(0); badge.setDepth(depth + 3); - badge.setStrokeStyle(1, selected ? palette.green : accent, selected ? 0.86 : 0.56); - this.trackSortie(this.add.text(cardX + cardWidth - 49, cardY + 20, badgeLabel, this.textStyle(9, selected ? '#e7ffd9' : '#d8ecff', true))) + badge.setStrokeStyle(1, selected ? palette.green : recommended ? palette.gold : accent, selected ? 0.86 : recommended ? 0.78 : 0.56); + this.trackSortie(this.add.text(cardX + cardWidth - 49, cardY + 20, badgeLabel, this.textStyle(9, selected ? '#e7ffd9' : recommended ? '#fff2b8' : '#d8ecff', true))) .setOrigin(0.5) .setDepth(depth + 4); }); - this.trackSortie(this.add.text(x + 18, y + height - 24, '명령 선택은 도전 목표·전적·최초 보상만 바꾸며 편성책과 보급을 유지합니다.', this.textStyle(10, '#9fb0bf', true))) + const footer = firstBattleFollowup + ? `회고 추천 · ${firstBattleFollowup.recommendationReason} · 선택 전에는 저장되지 않습니다.` + : '명령 선택은 도전 목표·전적·최초 보상만 바꾸며 편성책과 보급을 유지합니다.'; + this.trackSortie(this.add.text(x + 18, y + height - 24, this.compactText(footer, width > 650 ? 92 : 72), this.textStyle(10, '#9fb0bf', true))) .setDepth(depth + 1); this.sortieOrderBrowserView = { closeButton, cards }; } @@ -20173,6 +20293,9 @@ export class CampScene extends Phaser.Scene { this.sortiePrimaryActionButton = undefined; this.sortieNextActionGuideBackground = undefined; this.sortieNextActionGuideText = undefined; + this.firstBattleFollowupCtaButton = undefined; + this.firstBattleFollowupCard = undefined; + this.firstBattleFollowupSummaryText = undefined; this.sortiePortraitRosterLayout = undefined; this.sortieRosterToggleBounds = []; this.sortieHoveredUnitId = undefined; @@ -22408,6 +22531,7 @@ export class CampScene extends Phaser.Scene { this.track(this.add.text(x + 24, y + 56, '출진 전 대화에서 선택지를 고르면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6'))); const dialogues = this.availableCampDialogues(); + const firstBattleFollowup = this.firstBattleCampFollowup(); const compactDialogueList = dialogues.length > 3; const dialogueRowGap = compactDialogueList ? 35 : 64; const dialogueRowHeight = compactDialogueList ? 30 : 48; @@ -22423,12 +22547,31 @@ export class CampScene extends Phaser.Scene { row.setOrigin(0); row.setStrokeStyle(1, completed ? palette.green : selected ? palette.gold : palette.blue, selected ? 0.72 : 0.46); row.setInteractive({ useHandCursor: true }); + this.campDialogueRowButtons[dialogue.id] = row; row.on('pointerdown', () => { soundDirector.playSelect(); this.selectedDialogueId = dialogue.id; this.render(); }); - this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 3 : 8), dialogue.title, this.textStyle(compactDialogueList ? 11 : 15, completed ? '#a8ffd0' : '#f2e3bf', true))); + const isFirstBattleFollowup = firstBattleFollowup?.targetDialogueId === dialogue.id; + const title = isFirstBattleFollowup + ? this.compactText(dialogue.title, compactDialogueList ? 20 : 14) + : dialogue.title; + this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 3 : 8), title, this.textStyle(compactDialogueList ? 11 : 15, completed ? '#a8ffd0' : '#f2e3bf', true))); + if (isFirstBattleFollowup) { + this.track( + this.add.text( + x + 320, + rowY + (compactDialogueList ? 4 : 9), + completed ? '완료' : '회고', + { + ...this.textStyle(compactDialogueList ? 8 : 9, completed ? '#a8ffd0' : '#fff2b8', true), + backgroundColor: completed ? '#21402d' : '#8a3f25', + padding: { x: 4, y: 2 } + } + ) + ).setOrigin(1, 0); + } const maxReward = dialogue.rewardExp + Math.max(...dialogue.choices.map((choice) => choice.rewardExp)); this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 17 : 29), `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(compactDialogueList ? 9 : 12, '#9fb0bf'))); }); @@ -22453,13 +22596,14 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.5); this.track(this.add.text(x + 18, y + 16, dialogue.title, this.textStyle(20, '#f2e3bf', true))); - this.track( - this.add.text(x + 18, y + 52, dialogue.lines.join('\n\n'), { - ...this.textStyle(15, '#d4dce6'), + const dialogueBody = this.track( + this.add.text(x + 18, y + 52, dialogue.lines.join('\n'), { + ...this.textStyle(14, '#d4dce6'), wordWrap: { width: width - 36, useAdvancedWrap: true }, - lineSpacing: 5 + lineSpacing: 3 }) ); + this.campDialogueBodyText = dialogueBody; if (completed) { const chosen = this.recordedDialogueChoice(dialogue); @@ -22487,6 +22631,7 @@ export class CampScene extends Phaser.Scene { const button = this.track(this.add.rectangle(x + width / 2, choiceY, width - 36, 34, 0x1a2630, 0.96)); button.setStrokeStyle(1, palette.gold, 0.76); button.setInteractive({ useHandCursor: true }); + this.campDialogueChoiceButtons[`${dialogue.id}:${choice.id}`] = button; button.on('pointerover', () => button.setFillStyle(0x283947, 0.98)); button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96)); button.on('pointerdown', () => this.completeDialogue(dialogue, choice)); @@ -23730,7 +23875,11 @@ export class CampScene extends Phaser.Scene { } private showDialogueReward(dialogue: CampDialogue, choice: CampDialogueChoice, rewardExp: number) { - this.showCampNotice(`획득 내역 · ${choice.label} · 공명 +${rewardExp}\n응답 · ${choice.response}`); + const followup = this.firstBattleCampFollowup(); + const preparationLine = followup?.targetDialogueId === dialogue.id + ? `\n다음 준비 · ${followup.recommendationTitle}` + : ''; + this.showCampNotice(`획득 내역 · ${choice.label} · 공명 +${rewardExp}\n응답 · ${choice.response}${preparationLine}`); } private showCampNotice(message: string) { @@ -24563,6 +24712,9 @@ export class CampScene extends Phaser.Scene { private clearContent() { this.contentObjects.forEach((object) => object.destroy()); this.contentObjects = []; + this.campDialogueRowButtons = {}; + this.campDialogueChoiceButtons = {}; + this.campDialogueBodyText = undefined; this.campRosterLayout = undefined; this.cityPanelBackground = undefined; this.cityExploreButton = undefined; @@ -24716,6 +24868,11 @@ export class CampScene extends Phaser.Scene { const cityDialogueComplete = cityStay ? this.completedCampDialogues().includes(cityStay.dialogue.id) : false; + const firstBattleCampFollowup = this.firstBattleCampFollowup(); + const availableCampDialogues = this.availableCampDialogues(); + const selectedCampDialogue = availableCampDialogues.find( + (dialogue) => dialogue.id === this.selectedDialogueId + ) ?? availableCampDialogues[0]; return { scene: this.scene.key, victoryRewardAcknowledgement: { @@ -24772,6 +24929,60 @@ export class CampScene extends Phaser.Scene { strokeColor: button.bg.strokeColor, lineWidth: button.bg.lineWidth })), + firstVictoryFollowup: firstBattleCampFollowup + ? { + available: true, + sourceBattleId: this.report?.battleId ?? null, + originalOrderId: firstBattleCampFollowup.originalOrderId, + originalOrderLabel: firstBattleCampFollowup.originalOrderLabel, + achieved: firstBattleCampFollowup.achieved, + focusCriterionId: firstBattleCampFollowup.focusCriterionId, + focusCriterionLabel: firstBattleCampFollowup.focusCriterionLabel, + statusLabel: firstBattleCampFollowup.statusLabel, + mentorUnitId: firstBattleCampFollowup.mentorUnitId, + mentorName: firstBattleCampFollowup.mentorName, + targetDialogueId: firstBattleCampFollowup.targetDialogueId, + dialogueTitle: firstBattleCampFollowup.dialogueTitle, + dialogueLines: [...firstBattleCampFollowup.dialogueLines], + dialogueCompleted: this.completedCampDialogues().includes(firstBattleCampFollowup.targetDialogueId), + dialogueRowBounds: this.sortieObjectBoundsDebug( + this.campDialogueRowButtons[firstBattleCampFollowup.targetDialogueId] + ), + recommendedOrderId: firstBattleCampFollowup.recommendedNextOrderId, + recommendedOrderLabel: firstBattleCampFollowup.recommendedNextOrderLabel, + recommendationTitle: firstBattleCampFollowup.recommendationTitle, + recommendationReason: firstBattleCampFollowup.recommendationReason, + handoffCardBounds: this.sortieObjectBoundsDebug(this.firstBattleFollowupCard), + handoffSummaryBounds: this.sortieTextBoundsDebug(this.firstBattleFollowupSummaryText), + ctaBounds: this.sortieInteractiveObjectBoundsDebug(this.firstBattleFollowupCtaButton), + ctaInteractive: Boolean(this.firstBattleFollowupCtaButton?.input?.enabled), + recommendedOrderSelected: this.currentSortieOrderId() === firstBattleCampFollowup.recommendedNextOrderId, + recommendedOrderCardBounds: this.sortieObjectBoundsDebug( + this.sortieOrderBrowserView?.cards[firstBattleCampFollowup.recommendedNextOrderId] + ) + } + : null, + selectedDialogue: selectedCampDialogue + ? { + id: selectedCampDialogue.id, + title: selectedCampDialogue.title, + unitIds: [...selectedCampDialogue.unitIds], + lines: [...selectedCampDialogue.lines], + completed: this.completedCampDialogues().includes(selectedCampDialogue.id), + firstVictoryFollowup: firstBattleCampFollowup?.targetDialogueId === selectedCampDialogue.id, + rowBounds: this.sortieObjectBoundsDebug(this.campDialogueRowButtons[selectedCampDialogue.id]), + bodyBounds: this.sortieTextBoundsDebug(this.campDialogueBodyText), + choices: selectedCampDialogue.choices.map((choice) => ({ + id: choice.id, + label: choice.label, + response: choice.response, + rewardExp: selectedCampDialogue.rewardExp + choice.rewardExp, + bounds: this.sortieInteractiveObjectBoundsDebug( + this.campDialogueChoiceButtons[`${selectedCampDialogue.id}:${choice.id}`] + ) + })) + } + : null, cityStay: cityStay ? { available: true, @@ -24871,6 +25082,7 @@ export class CampScene extends Phaser.Scene { formationPanelMode: this.sortieFormationPanelMode, selectionBattleId: this.campaign?.sortieOrderSelection?.battleId ?? null, selectedId: sortieOrderId ?? null, + recommendedId: firstBattleCampFollowup?.recommendedNextOrderId ?? null, complete: this.hasCurrentSortieOrderSelection(), toggleButtonBounds: this.sortieObjectBoundsDebug(this.sortieOrderToggleButton), closeButtonBounds: this.sortieObjectBoundsDebug(this.sortieOrderBrowserView?.closeButton), @@ -24884,6 +25096,7 @@ export class CampScene extends Phaser.Scene { summary: definition.summary, condition: definition.condition, selected: sortieOrderId === definition.id, + recommended: firstBattleCampFollowup?.recommendedNextOrderId === definition.id, attempts: summary?.attempts ?? 0, successes: summary?.successes ?? 0, successRate: summary?.successRate ?? 0,