feat: turn first victory recall into camp preparation

This commit is contained in:
2026-07-27 05:56:17 +09:00
parent 0c16ff3c03
commit b963ef34cb
7 changed files with 1143 additions and 42 deletions

View File

@@ -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):
모호한 전략층을 명확한 보상, 미리 계획 가능한 선택과 단일 화면으로 압축

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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');

View File

@@ -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',

View File

@@ -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<CampaignSortieOrderId, FollowupVoice> = {
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'
};
}

View File

@@ -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<string, Phaser.GameObjects.Rectangle> = {};
private campDialogueChoiceButtons: Record<string, Phaser.GameObjects.Rectangle> = {};
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<CampTab>();
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,