feat: carry camp promises into first victory

This commit is contained in:
2026-07-27 05:01:17 +09:00
parent 4d3f8b6a28
commit 0c16ff3c03
11 changed files with 888 additions and 23 deletions

View File

@@ -95,9 +95,27 @@ RPG로 다듬는다.
때만 사이드 패널에서 제한을 설명한다. 때만 사이드 패널에서 제한을 설명한다.
- 필수 점검과 군령 반응의 중복 대사를 줄여 진행 입력을 약 7회 줄인다. - 필수 점검과 군령 반응의 중복 대사를 줄여 진행 입력을 약 7회 줄인다.
다음 초반 묶음은 젊은 의병의 격려가 첫 전투 중 짧은 반응으로 돌아오고, ### 완료한 3차 묶음: 막사의 약속과 첫 승리 회고
첫 승리 뒤 막사 인물들이 플레이어가 고른 군령과 전투 결과를 서로 다르게
기억하도록 연결하는 것을 우선한다. - 젊은 의병과 끝까지 대화하면 선택적 약속을 저장하되, 필수 점검 수나
출전 조건에는 포함하지 않는다.
- 약속한 첫 전투의 실제 첫 공격에서 기존 자동 소멸 안내의 세 번째 줄로
의병의 반응을 회수한다. 새 확인창이나 추가 입력은 만들지 않는다.
- 대화하지 않았을 때는 기존 두 줄 안내를 유지하고, 이후 전투에는 약속이
나타나지 않는다.
- 첫 승리 뒤 관우와 장비는 저장된 실제 군령, 달성 여부, 실패 조건, 턴과
전공을 서로 다른 말투로 회고한다.
- 관우는 조건과 다음 보완점을 분석하고, 장비는 달성의 기세와 사람의
전공을 강조한다.
- 회고 문장은 저장 데이터에서 매번 파생하고 공유 이야기 원본을 바꾸지
않는다. 오래된 저장처럼 군령 결과가 없거나 불완전하면 기존 대사를
그대로 사용한다.
- 세 군령의 달성·미달 여섯 조합, 선택 대화의 저장·재개, 첫 교전 중복
방지, 이후 전투 누출 방지와 1920x1080 배치를 자동 검증한다.
다음 초반 묶음은 첫 승리에서 드러난 군령의 남은 과제가 첫 군영의 동료
대화와 다음 편성 제안으로 이어지게 해, 회고가 설명으로 끝나지 않고 다음
준비 행동을 바꾸도록 연결하는 것을 우선한다.
## 장기 개선 순서 ## 장기 개선 순서

View File

@@ -95,7 +95,10 @@ assert.match(deploymentPanel, /'준비 중 · 누르면 자동 시작'/);
assert.match(deploymentPanel, /combatAssetsReady\s*\? ' '/s); assert.match(deploymentPanel, /combatAssetsReady\s*\? ' '/s);
const resolveDamageTarget = privateMethodBody(battleSource, 'tryResolveDamageTarget'); const resolveDamageTarget = privateMethodBody(battleSource, 'tryResolveDamageTarget');
assert.match(resolveDamageTarget, /triggerBattleEvent\('first-engagement'[\s\S]*\{ playCue: false \}\)/); assert.match(resolveDamageTarget, /triggerFirstEngagementEvent\(attacker, target\)/);
const firstEngagementEvent = privateMethodBody(battleSource, 'triggerFirstEngagementEvent');
assert.match(firstEngagementEvent, /volunteerPromiseLineForBattle\(/);
assert.match(firstEngagementEvent, /triggerBattleEvent\(firstBattleVolunteerPromiseEventKey[\s\S]*\{ playCue: false \}\)/);
const triggerBattleEvent = privateMethodBody(battleSource, 'triggerBattleEvent'); const triggerBattleEvent = privateMethodBody(battleSource, 'triggerBattleEvent');
assert.match(triggerBattleEvent, /options\.playCue !== false/); assert.match(triggerBattleEvent, /options\.playCue !== false/);

View File

@@ -380,6 +380,11 @@ try {
{ id: 'inspect-arms', completed: false, worldCueVisible: false } { id: 'inspect-arms', completed: false, worldCueVisible: false }
] ]
); );
assert.deepEqual(militiaCamp.optionalDialogues, [{
npcId: 'nervous-volunteer',
tutorialId: 'prologue-camp-reassure-volunteer',
completed: false
}]);
assert.equal(militiaCamp.exitUnlocked, false); assert.equal(militiaCamp.exitUnlocked, false);
assert.equal( assert.equal(
militiaCamp.dialogue.active, militiaCamp.dialogue.active,
@@ -475,6 +480,48 @@ try {
'Zou Jing must not release the sortie before every camp inspection is complete.' 'Zou Jing must not release the sortie before every camp inspection is complete.'
); );
await teleportMilitiaCampTo(page, 'nervous-volunteer');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'nervous-volunteer'
));
let volunteerDialogue = await readMilitiaCamp(page);
assert.equal(volunteerDialogue.dialogue.totalLines, 2);
assert.equal(
volunteerDialogue.dialogue.portraitTextureKey,
'portrait-zhuo-young-volunteer-yellow-turban'
);
assertBoundsInsideViewport(volunteerDialogue.dialogue.bounds, 'nervous volunteer dialogue');
assert.equal(volunteerDialogue.optionalDialogues[0].completed, false);
assert(
!(await readCampaignSave(page)).completedTutorialIds.includes('prologue-camp-reassure-volunteer'),
'Opening the optional conversation must not record a promise before it is finished.'
);
await page.mouse.click(960, 850);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.speaker === '유비'
));
assert(
!(await readCampaignSave(page)).completedTutorialIds.includes('prologue-camp-reassure-volunteer'),
'The optional promise must wait until the final line closes.'
);
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-volunteer-promise.png');
await advanceMilitiaCampDialogueUntilClosed(page);
volunteerDialogue = await readMilitiaCamp(page);
assert.equal(volunteerDialogue.optionalDialogues[0].completed, true);
assert.deepEqual(
volunteerDialogue.completedObjectiveIds,
['inspect-arms'],
'The optional promise must not change the required preparation count.'
);
assert.equal(volunteerDialogue.exitUnlocked, false);
const savedVolunteerPromise = await readCampaignSaves(page);
assert(
savedVolunteerPromise.current.completedTutorialIds.includes('prologue-camp-reassure-volunteer') &&
savedVolunteerPromise.slot1.completedTutorialIds.includes('prologue-camp-reassure-volunteer'),
'The completed optional promise must persist to both the current save and active slot.'
);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page); await waitForDebugApi(page);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene')); await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene'));
@@ -496,6 +543,22 @@ try {
false, false,
'The one-time militia camp orientation must not replay on Continue.' 'The one-time militia camp orientation must not replay on Continue.'
); );
assert.equal(
militiaCamp.optionalDialogues[0].completed,
true,
'The optional volunteer promise must survive reload and Continue.'
);
await teleportMilitiaCampTo(page, 'nervous-volunteer');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'nervous-volunteer'
));
assert.equal(
(await readMilitiaCamp(page)).dialogue.totalLines,
1,
'A returning player should hear the volunteer remember the promise instead of repeating it.'
);
await advanceMilitiaCampDialogueUntilClosed(page);
await interactWithMilitiaCampNpc(page, 'guan-yu'); await interactWithMilitiaCampNpc(page, 'guan-yu');
await interactWithMilitiaCampNpc(page, 'zhang-fei'); await interactWithMilitiaCampNpc(page, 'zhang-fei');
@@ -711,8 +774,43 @@ try {
count: 1 count: 1
}); });
assert.equal(firstBattle.itemStocks['guan-yu'].salve, 1); assert.equal(firstBattle.itemStocks['guan-yu'].salve, 1);
assert.deepEqual(firstBattle.firstBattleNarrativeMemory.volunteerPromise, {
completedInCamp: true,
eligible: true,
eventKey: 'first-engagement',
line: '젊은 의병 · 말씀대로 곁의 동료와 보조를 맞추겠습니다.',
eventState: 'waiting',
bannerBounds: null
});
await captureStableScreenshot(page, 'dist/verification-first-battle-preparation.png'); await captureStableScreenshot(page, 'dist/verification-first-battle-preparation.png');
const triggeredFirstEngagement = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
return scene?.debugTriggerFirstEngagementEvent?.();
});
assert.equal(triggeredFirstEngagement, true);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.battle?.()?.activeBattleEvent?.key === 'first-engagement'
));
const volunteerEngagement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.());
assert.equal(volunteerEngagement.activeBattleEvent.lines.length, 3);
assert.equal(
volunteerEngagement.activeBattleEvent.lines[2],
volunteerEngagement.firstBattleNarrativeMemory.volunteerPromise.line
);
assert.equal(
volunteerEngagement.firstBattleNarrativeMemory.volunteerPromise.eventState,
'active'
);
assertBoundsInsideViewport(
volunteerEngagement.firstBattleNarrativeMemory.volunteerPromise.bannerBounds,
'first-engagement volunteer memory banner'
);
await captureStableScreenshot(page, 'dist/verification-first-battle-volunteer-memory.png');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.battle?.()?.firstBattleNarrativeMemory?.volunteerPromise?.eventState === 'completed'
), undefined, { timeout: 10000 });
await page.evaluate(() => { await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.saveBattleState?.(1); scene?.saveBattleState?.(1);
@@ -730,6 +828,20 @@ try {
'Saving and resuming the first battle must not grant the preparation salve twice.' 'Saving and resuming the first battle must not grant the preparation salve twice.'
); );
assert.equal(resumedFirstBattle.itemStocks['guan-yu'].salve, 1); assert.equal(resumedFirstBattle.itemStocks['guan-yu'].salve, 1);
assert.equal(
resumedFirstBattle.firstBattleNarrativeMemory.volunteerPromise.eventState,
'completed',
'Saving and resuming must not replay an already presented first-engagement memory.'
);
const duplicateEngagement = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
return scene?.debugTriggerFirstEngagementEvent?.();
});
assert.equal(duplicateEngagement, false);
assert.equal(
resumedFirstBattle.triggeredBattleEvents.filter((id) => id === 'first-engagement').length,
1
);
await page.evaluate(() => { await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -749,6 +861,27 @@ try {
secondBattle.firstBattlePreparation.callbacks.every(({ completed }) => completed === false), secondBattle.firstBattlePreparation.callbacks.every(({ completed }) => completed === false),
'Militia-camp preparation callbacks must not leak into later battles.' 'Militia-camp preparation callbacks must not leak into later battles.'
); );
assert.equal(secondBattle.firstBattleNarrativeMemory.volunteerPromise.completedInCamp, true);
assert.equal(secondBattle.firstBattleNarrativeMemory.volunteerPromise.eligible, false);
assert.equal(secondBattle.firstBattleNarrativeMemory.volunteerPromise.line, null);
const secondBattleEngagementTriggered = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
return scene?.debugTriggerFirstEngagementEvent?.();
});
assert.equal(secondBattleEngagementTriggered, true);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.battle?.()?.activeBattleEvent?.key === 'first-engagement'
));
const secondBattleEngagement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.());
assert.equal(
secondBattleEngagement.activeBattleEvent.lines.length,
2,
'The camp promise must not leak into a later battle that reuses the first-engagement event.'
);
assert(
secondBattleEngagement.activeBattleEvent.lines.every((line) => !line.includes('젊은 의병')),
'Later first-engagement text must not mention the prologue volunteer.'
);
await seedLegacyCompletedVillageSave(page, battleSave); await seedLegacyCompletedVillageSave(page, battleSave);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
@@ -774,7 +907,8 @@ try {
console.log( console.log(
`Verified the playable prologue village and militia camp at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + `Verified the playable prologue village and militia camp at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
`DPR ${desktopBrowserDeviceScaleFactor}: sequential meetings, direct movement, distance-gated dialogue, ` + `DPR ${desktopBrowserDeviceScaleFactor}: sequential meetings, direct movement, distance-gated dialogue, ` +
'autosave/reload, legacy-save routing, locked oath and command, camp preparation, departure story, and first deployment.' 'autosave/reload, legacy-save routing, locked oath and command, camp preparation, optional promise memory, ' +
'departure story, and first deployment.'
); );
} finally { } finally {
await browser?.close(); await browser?.close();
@@ -808,10 +942,19 @@ async function clickLegacyTitleControl(page, x, y) {
} }
async function waitForStoryReady(page) { async function waitForStoryReady(page) {
await page.waitForFunction(() => { try {
const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.(); await page.waitForFunction(() => {
return window.__HEROS_DEBUG__?.activeScenes?.().includes('StoryScene') && story?.ready === true; const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.();
}, undefined, { timeout: 90000 }); return window.__HEROS_DEBUG__?.activeScenes?.().includes('StoryScene') && story?.ready === true;
}, undefined, { timeout: 90000 });
} catch (error) {
const diagnostic = await page.evaluate(() => ({
activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null,
militiaCamp: window.__HEROS_DEBUG__?.militiaCamp?.() ?? null
}));
throw new Error(`Story scene did not become ready: ${JSON.stringify(diagnostic)}`, { cause: error });
}
} }
async function waitForVillageReady(page) { async function waitForVillageReady(page) {
@@ -1006,6 +1149,19 @@ async function readCampaignSave(page) {
}); });
} }
async function readCampaignSaves(page) {
return page.evaluate(() => {
const parse = (key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
};
return {
current: parse('heros-web:campaign-state'),
slot1: parse('heros-web:campaign-state:slot-1')
};
});
}
async function assertDesktopViewport(page) { async function assertDesktopViewport(page) {
const viewport = await page.evaluate(() => { const viewport = await page.evaluate(() => {
const canvas = document.querySelector('canvas'); const canvas = document.querySelector('canvas');

View File

@@ -27,6 +27,7 @@ const expectedCampMarkerIds = [
'prologue-camp-review-scout-report', 'prologue-camp-review-scout-report',
'prologue-camp-ready-vanguard', 'prologue-camp-ready-vanguard',
'prologue-camp-inspect-arms', 'prologue-camp-inspect-arms',
'prologue-camp-reassure-volunteer',
'prologue-camp-complete' 'prologue-camp-complete'
]; ];
const expectedFirstCommandOrderIds = ['elite', 'mobile', 'siege']; const expectedFirstCommandOrderIds = ['elite', 'mobile', 'siege'];
@@ -40,6 +41,8 @@ const server = await createServer({
try { try {
const village = await server.ssrLoadModule('/src/game/data/prologueVillage.ts'); const village = await server.ssrLoadModule('/src/game/data/prologueVillage.ts');
const militiaCamp = await server.ssrLoadModule('/src/game/data/prologueMilitiaCamp.ts'); const militiaCamp = await server.ssrLoadModule('/src/game/data/prologueMilitiaCamp.ts');
const narrativeMemory = await server.ssrLoadModule('/src/game/data/firstBattleNarrativeMemory.ts');
const scenario = await server.ssrLoadModule('/src/game/data/scenario.ts');
const sortieOrders = await server.ssrLoadModule('/src/game/data/sortieOrders.ts'); const sortieOrders = await server.ssrLoadModule('/src/game/data/sortieOrders.ts');
const campaign = await server.ssrLoadModule('/src/game/state/campaignState.ts'); const campaign = await server.ssrLoadModule('/src/game/state/campaignState.ts');
const unitAssets = await server.ssrLoadModule('/src/game/data/unitAssets.ts'); const unitAssets = await server.ssrLoadModule('/src/game/data/unitAssets.ts');
@@ -262,6 +265,14 @@ try {
'militia camp should include at least one optional volunteer conversation', 'militia camp should include at least one optional volunteer conversation',
failures failures
); );
const nervousVolunteer = campNpcs.find((npc) => npc.id === 'nervous-volunteer');
assert(
nervousVolunteer?.dialogue.length === 2 &&
nervousVolunteer.repeatDialogue?.length === 1 &&
dialogueText(nervousVolunteer).includes('보조를 맞추고'),
'the nervous volunteer should make one optional promise and use a concise repeat line',
failures
);
assert( assert(
!JSON.stringify(campNpcs).includes('피란'), !JSON.stringify(campNpcs).includes('피란'),
'militia camp dialogue must use the established 피난 terminology consistently', 'militia camp dialogue must use the established 피난 terminology consistently',
@@ -302,6 +313,12 @@ try {
'prologue-camp must be recognized by campaign progress summaries', 'prologue-camp must be recognized by campaign progress summaries',
failures failures
); );
verifyFirstBattleNarrativeMemory(
narrativeMemory,
scenario.firstBattleVictoryPages,
sortieOrders,
failures
);
if (failures.length > 0) { if (failures.length > 0) {
throw new Error( throw new Error(
@@ -313,7 +330,8 @@ try {
console.log( console.log(
`Verified ${npcs.length} village NPCs, ${campNpcs.length} militia camp NPCs, ` + `Verified ${npcs.length} village NPCs, ${campNpcs.length} militia camp NPCs, ` +
`${openingPages.length + brotherhoodPages.length + departurePages.length} prologue story pages, ` + `${openingPages.length + brotherhoodPages.length + departurePages.length} prologue story pages, ` +
`${firstCommandChoices.length} first-command choices, sequential meetings, camp preparation, and save markers.` `${firstCommandChoices.length} first-command choices, sequential meetings, camp preparation, ` +
'optional promise memory, six victory-recall variants, and save markers.'
); );
} finally { } finally {
await server.close(); await server.close();
@@ -333,6 +351,169 @@ function assertArrayEquals(actual, expected, label, failures) {
); );
} }
function verifyFirstBattleNarrativeMemory(memory, sourcePages, sortieOrders, failures) {
const battleId = 'first-battle-zhuo-commandery';
const pageSnapshot = JSON.stringify(sourcePages);
const frozenPages = deepFreeze(sourcePages.map((page) => ({
...page,
...(page.cutscene ? { cutscene: structuredClone(page.cutscene) } : {})
})));
const progressIdsByOrder = {
elite: ['victory', 'elite-grade', 'elite-survival'],
mobile: ['victory', 'mobile-quick', 'mobile-breakthrough'],
siege: ['victory', 'siege-objective', 'siege-front', 'siege-support']
};
const missedIdByOrder = {
elite: 'elite-survival',
mobile: 'mobile-quick',
siege: 'siege-support'
};
const missedLabelByOrder = {
elite: '전원 생존',
mobile: '제한 턴',
siege: '후원 기여'
};
for (const orderId of ['elite', 'mobile', 'siege']) {
for (const achieved of [true, false]) {
const failedProgressId = achieved ? null : missedIdByOrder[orderId];
const report = {
battleId,
outcome: 'victory',
turnNumber: 7,
mvp: { unitId: 'liu-bei', name: ' 유비 ' },
sortieOrder: {
orderId,
achieved,
rewardGranted: false,
progress: progressIdsByOrder[orderId].map((id) => ({
id,
achieved: id !== failedProgressId,
value: id === failedProgressId ? 0 : 1,
target: 1
}))
}
};
const resolved = memory.resolveFirstBattleVictoryNarrative(frozenPages, report);
const guanYuPage = resolved.pages.find((page) => page.id === 'first-victory-guan-yu');
const zhangFeiPage = resolved.pages.find((page) => page.id === 'first-victory-zhang-fei');
const orderLabel = sortieOrders.sortieOrderDisplayLabel(battleId, orderId);
const label = `${orderId}/${achieved ? 'achieved' : 'missed'}`;
assert(resolved.memory.source === 'campaign', `${label} must use campaign memory`, failures);
assert(resolved.memory.orderId === orderId, `${label} must retain its order id`, failures);
assert(resolved.memory.orderLabel === orderLabel, `${label} must use the contextual order label`, failures);
assert(resolved.memory.achieved === achieved, `${label} must branch on achieved, not rewardGranted`, failures);
assert(resolved.memory.turnNumber === 7, `${label} must retain the actual turn`, failures);
assert(resolved.memory.mvp?.name === '유비', `${label} must normalize the MVP name`, failures);
assert(
guanYuPage?.text.includes(`${orderLabel} 군령`) &&
zhangFeiPage?.text.includes(`${orderLabel} 군령`),
`${label} dialogue must name the selected order`,
failures
);
assert(
guanYuPage?.text !== zhangFeiPage?.text,
`${label} must give Guan Yu and Zhang Fei distinct voices`,
failures
);
assert(
(guanYuPage?.text.length ?? 999) <= 96 && (zhangFeiPage?.text.length ?? 999) <= 96,
`${label} dialogue must fit the existing FHD story panel`,
failures
);
if (achieved) {
assert(
resolved.memory.missedCriterionIds.length === 0 &&
guanYuPage?.text.includes('모두 지켰') &&
zhangFeiPage?.text.includes('완수'),
`${label} must clearly remember command completion`,
failures
);
} else {
assert(
resolved.memory.missedCriterionIds.includes(failedProgressId) &&
guanYuPage?.text.includes(missedLabelByOrder[orderId]) &&
zhangFeiPage?.text.includes(missedLabelByOrder[orderId]),
`${label} must name the missed criterion`,
failures
);
}
if (orderId === 'mobile') {
assert(
guanYuPage?.text.includes('7턴') && zhangFeiPage?.text.includes('7턴'),
`${label} must remember the actual battle turn`,
failures
);
}
assert(
resolved.pages.every((page, index) => page !== frozenPages[index]),
`${label} must return new page objects`,
failures
);
assert(
resolved.pages.every((page, index) => (
['first-victory-guan-yu', 'first-victory-zhang-fei'].includes(page.id) ||
page.text === frozenPages[index].text
)),
`${label} must change only the two companion recall pages`,
failures
);
assert(
JSON.stringify(frozenPages) === pageSnapshot &&
JSON.stringify(sourcePages) === pageSnapshot,
`${label} must not mutate shared story pages`,
failures
);
}
}
const fallbackReports = [
[undefined, 'missing-report'],
[{ battleId: 'other-battle', outcome: 'victory' }, 'battle-mismatch'],
[{ battleId, outcome: 'defeat' }, 'non-victory'],
[{ battleId, outcome: 'victory' }, 'missing-order'],
[{
battleId,
outcome: 'victory',
sortieOrder: { orderId: 'elite', achieved: true, progress: [] }
}, 'invalid-order-progress']
];
fallbackReports.forEach(([report, reason]) => {
const resolved = memory.resolveFirstBattleVictoryNarrative(frozenPages, report);
assert(
resolved.memory.source === 'fallback' &&
resolved.memory.fallbackReason === reason &&
JSON.stringify(resolved.pages) === pageSnapshot,
`${reason} must preserve the authored victory dialogue exactly`,
failures
);
});
assert(
memory.volunteerPromiseLineForBattle(battleId, true) === memory.firstBattleVolunteerPromiseText &&
memory.firstBattleVolunteerPromiseText.length <= 42,
'the reassured volunteer should return in the existing three-line first-engagement banner',
failures
);
assert(
memory.volunteerPromiseLineForBattle(battleId, false) === undefined &&
memory.volunteerPromiseLineForBattle('second-battle-yellow-turban-pursuit', true) === undefined,
'the optional promise must not appear when skipped or leak into later battles',
failures
);
}
function deepFreeze(value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) {
return value;
}
Object.freeze(value);
Object.values(value).forEach(deepFreeze);
return value;
}
function verifyNpcs(npcs, locationLabel, unitAssets, failures) { function verifyNpcs(npcs, locationLabel, unitAssets, failures) {
assert( assert(
new Set(npcs.map((npc) => npc.id)).size === npcs.length, new Set(npcs.map((npc) => npc.id)).size === npcs.length,

View File

@@ -1277,12 +1277,14 @@ try {
const originalPlayCombatCutIn = scene.playCombatCutIn; const originalPlayCombatCutIn = scene.playCombatCutIn;
const originalApplyDefeatedStyle = scene.applyDefeatedStyle; const originalApplyDefeatedStyle = scene.applyDefeatedStyle;
const originalResolveCounterAttack = scene.resolveCounterAttack; const originalResolveCounterAttack = scene.resolveCounterAttack;
const originalTriggerBattleEvent = scene.triggerBattleEvent;
const originalCombatPresentationMode = scene.combatPresentationMode; const originalCombatPresentationMode = scene.combatPresentationMode;
const methodOwnership = { const methodOwnership = {
resolveCombatAction: Object.prototype.hasOwnProperty.call(scene, 'resolveCombatAction'), resolveCombatAction: Object.prototype.hasOwnProperty.call(scene, 'resolveCombatAction'),
playCombatCutIn: Object.prototype.hasOwnProperty.call(scene, 'playCombatCutIn'), playCombatCutIn: Object.prototype.hasOwnProperty.call(scene, 'playCombatCutIn'),
applyDefeatedStyle: Object.prototype.hasOwnProperty.call(scene, 'applyDefeatedStyle'), applyDefeatedStyle: Object.prototype.hasOwnProperty.call(scene, 'applyDefeatedStyle'),
resolveCounterAttack: Object.prototype.hasOwnProperty.call(scene, 'resolveCounterAttack') resolveCounterAttack: Object.prototype.hasOwnProperty.call(scene, 'resolveCounterAttack'),
triggerBattleEvent: Object.prototype.hasOwnProperty.call(scene, 'triggerBattleEvent')
}; };
let primaryResult = null; let primaryResult = null;
let counterResult = null; let counterResult = null;
@@ -1295,6 +1297,7 @@ try {
let counterProduced = false; let counterProduced = false;
const cutInOrder = []; const cutInOrder = [];
const defeatApplications = []; const defeatApplications = [];
let firstEngagementEvent = null;
const restoreMethod = (name, original) => { const restoreMethod = (name, original) => {
if (methodOwnership[name]) { if (methodOwnership[name]) {
@@ -1349,6 +1352,12 @@ try {
counterProduced = counterProduced || Boolean(counter); counterProduced = counterProduced || Boolean(counter);
return counter; return counter;
}; };
scene.triggerBattleEvent = function (key, title, lines, options) {
if (key === 'first-engagement') {
firstEngagementEvent = { key, title, lines: [...lines], options: { ...options } };
}
return originalTriggerBattleEvent.call(this, key, title, lines, options);
};
try { try {
scene.phase = 'targeting'; scene.phase = 'targeting';
@@ -1371,13 +1380,18 @@ try {
visibleAfterWrapper: viewState(), visibleAfterWrapper: viewState(),
counterCalls, counterCalls,
counterProduced, counterProduced,
cutInOrder cutInOrder,
firstEngagementEvent,
firstBattleNarrativeMemory: clone(
scene.getDebugState().firstBattleNarrativeMemory
)
}; };
} finally { } finally {
restoreMethod('resolveCombatAction', originalResolveCombatAction); restoreMethod('resolveCombatAction', originalResolveCombatAction);
restoreMethod('playCombatCutIn', originalPlayCombatCutIn); restoreMethod('playCombatCutIn', originalPlayCombatCutIn);
restoreMethod('applyDefeatedStyle', originalApplyDefeatedStyle); restoreMethod('applyDefeatedStyle', originalApplyDefeatedStyle);
restoreMethod('resolveCounterAttack', originalResolveCounterAttack); restoreMethod('resolveCounterAttack', originalResolveCounterAttack);
restoreMethod('triggerBattleEvent', originalTriggerBattleEvent);
scene.combatPresentationMode = originalCombatPresentationMode; scene.combatPresentationMode = originalCombatPresentationMode;
if (hadOwnRollPercent) { if (hadOwnRollPercent) {
scene.rollPercent = originalRollPercent; scene.rollPercent = originalRollPercent;
@@ -1517,6 +1531,16 @@ try {
JSON.stringify(productionFinisher.cutInOrder) === JSON.stringify(['primary']), JSON.stringify(productionFinisher.cutInOrder) === JSON.stringify(['primary']),
`Expected the production attack path to keep a pursuit-defeated target visible through the finisher, then hide it and suppress counterattack: ${JSON.stringify(productionFinisher)}` `Expected the production attack path to keep a pursuit-defeated target visible through the finisher, then hide it and suppress counterattack: ${JSON.stringify(productionFinisher)}`
); );
assert(
productionFinisher.firstEngagementEvent?.lines?.length === 2 &&
productionFinisher.firstEngagementEvent.lines.every((line) => !line.includes('젊은 의병')) &&
productionFinisher.firstBattleNarrativeMemory?.volunteerPromise?.completedInCamp === false &&
productionFinisher.firstBattleNarrativeMemory.volunteerPromise.eligible === false,
`Expected a skipped volunteer conversation to retain the original two-line first engagement: ${JSON.stringify({
event: productionFinisher.firstEngagementEvent,
memory: productionFinisher.firstBattleNarrativeMemory
})}`
);
const productionSurvivor = firstBattleBondChainJudgement.scenarios.productionSurvivor; const productionSurvivor = firstBattleBondChainJudgement.scenarios.productionSurvivor;
assert( assert(
productionSurvivor.result?.hit === true && productionSurvivor.result?.hit === true &&
@@ -2192,8 +2216,9 @@ try {
firstSortieOrder?.open === false && firstSortieOrder?.open === false &&
firstSortieOrder?.toggleButtonBounds && firstSortieOrder?.toggleButtonBounds &&
firstSortieOrderResult?.orderId === 'elite' && firstSortieOrderResult?.orderId === 'elite' &&
firstSortieOrderResult?.achieved === true &&
firstSortieOrderResult?.progress?.map((entry) => entry.id).join(',') === 'victory,elite-grade,elite-survival', firstSortieOrderResult?.progress?.map((entry) => entry.id).join(',') === 'victory,elite-grade,elite-survival',
`Expected the scripted first battle to expose an explicit elite order result: ${JSON.stringify(firstSortieOrder)}` `Expected the scripted first battle to complete and expose its elite order result: ${JSON.stringify(firstSortieOrder)}`
); );
assert( assert(
resultProbe.resultTexts.includes(`군자금 +${expectedFirstDisplayedRewardGold}`), resultProbe.resultTexts.includes(`군자금 +${expectedFirstDisplayedRewardGold}`),
@@ -2349,7 +2374,10 @@ try {
assert( assert(
directFirstAftermath?.pageIndex === 0 && directFirstAftermath?.pageIndex === 0 &&
directFirstAftermath?.presentation?.battleId === 'first-battle-zhuo-commandery' && directFirstAftermath?.presentation?.battleId === 'first-battle-zhuo-commandery' &&
directFirstAftermath.presentation.stage === 'aftermath', directFirstAftermath.presentation.stage === 'aftermath' &&
directFirstAftermath.firstBattleNarrativeMemory?.source === 'campaign' &&
directFirstAftermath.firstBattleNarrativeMemory.orderId === firstSortieOrderResult.orderId &&
directFirstAftermath.firstBattleNarrativeMemory.achieved === firstSortieOrderResult.achieved,
`Expected the victory CTA to enter the completed battle's aftermath: ${JSON.stringify(directFirstAftermath)}` `Expected the victory CTA to enter the completed battle's aftermath: ${JSON.stringify(directFirstAftermath)}`
); );
await page.keyboard.press('Space'); await page.keyboard.press('Space');
@@ -2373,6 +2401,16 @@ try {
resumedFirstAftermathSave.slot1?.pendingAftermathBattleId === 'first-battle-zhuo-commandery', resumedFirstAftermathSave.slot1?.pendingAftermathBattleId === 'first-battle-zhuo-commandery',
`Expected continue after an interrupted aftermath to restore that aftermath before camp: ${JSON.stringify({ story: resumedFirstAftermath, save: resumedFirstAftermathSave })}` `Expected continue after an interrupted aftermath to restore that aftermath before camp: ${JSON.stringify({ story: resumedFirstAftermath, save: resumedFirstAftermathSave })}`
); );
assert(
sameJsonValue(
resumedFirstAftermath.firstBattleNarrativeMemory,
directFirstAftermath.firstBattleNarrativeMemory
),
`Expected an interrupted aftermath to derive the same first-battle memory after reload: ${JSON.stringify({
direct: directFirstAftermath.firstBattleNarrativeMemory,
resumed: resumedFirstAftermath.firstBattleNarrativeMemory
})}`
);
const firstCampTransition = await waitForCampAfterBattleResult(page); const firstCampTransition = await waitForCampAfterBattleResult(page);
assert( assert(
firstCampTransition.lastStory?.isLastPage === true && firstCampTransition.lastStory?.isLastPage === true &&
@@ -2397,6 +2435,30 @@ try {
const firstRewardDisplaysByPage = Object.fromEntries( const firstRewardDisplaysByPage = Object.fromEntries(
firstCampTransition.rewardDisplays.map((display) => [display.pageId, display]) firstCampTransition.rewardDisplays.map((display) => [display.pageId, display])
); );
const firstNarrativeMemoriesByPage = Object.fromEntries(
firstCampTransition.narrativeMemories.map((memory) => [memory.pageId, memory])
);
const persistedFirstReport = firstResultSave.current?.firstBattleReport;
const guanYuMemory = firstNarrativeMemoriesByPage['first-victory-guan-yu'];
const zhangFeiMemory = firstNarrativeMemoriesByPage['first-victory-zhang-fei'];
assert(
firstCampTransition.narrativeMemories.length === 2 &&
guanYuMemory?.speaker === '관우' &&
zhangFeiMemory?.speaker === '장비' &&
guanYuMemory.text !== zhangFeiMemory.text &&
guanYuMemory.text.includes('정예 군령') &&
zhangFeiMemory.text.includes('정예 군령') &&
guanYuMemory.memory.orderId === persistedFirstReport?.sortieOrder?.orderId &&
zhangFeiMemory.memory.orderId === persistedFirstReport?.sortieOrder?.orderId &&
guanYuMemory.memory.achieved === persistedFirstReport?.sortieOrder?.achieved &&
zhangFeiMemory.memory.achieved === persistedFirstReport?.sortieOrder?.achieved &&
guanYuMemory.memory.turnNumber === persistedFirstReport?.turnNumber &&
sameJsonValue(
guanYuMemory.memory.progress,
persistedFirstReport?.sortieOrder?.progress
),
`Expected Guan Yu and Zhang Fei to remember the persisted first command in distinct voices: ${JSON.stringify(firstCampTransition.narrativeMemories)}`
);
const persistedFirstRewards = firstResultSave.current?.firstBattleReport?.campaignRewards; const persistedFirstRewards = firstResultSave.current?.firstBattleReport?.campaignRewards;
const expectedFirstStorySupplies = [ const expectedFirstStorySupplies = [
...(persistedFirstRewards?.supplies ?? []), ...(persistedFirstRewards?.supplies ?? []),
@@ -9432,6 +9494,7 @@ async function waitForCampAfterBattleResult(page) {
let lastStory = null; let lastStory = null;
const rewardDisplays = new Map(); const rewardDisplays = new Map();
const narrativeMemories = new Map();
for (let i = 0; i < 48; i += 1) { for (let i = 0; i < 48; i += 1) {
const probe = await page.evaluate(() => ({ const probe = await page.evaluate(() => ({
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
@@ -9447,16 +9510,45 @@ async function waitForCampAfterBattleResult(page) {
) { ) {
rewardDisplays.set(probe.story.rewardDisplay.pageId, probe.story.rewardDisplay); rewardDisplays.set(probe.story.rewardDisplay.pageId, probe.story.rewardDisplay);
} }
if (
probe.story?.firstBattleNarrativeMemory?.source === 'campaign' &&
probe.story.firstBattleNarrativeMemory.currentPageApplied === true &&
['first-victory-guan-yu', 'first-victory-zhang-fei'].includes(probe.story.currentPageId)
) {
const pageId = probe.story.currentPageId;
if (!narrativeMemories.has(pageId)) {
narrativeMemories.set(pageId, {
pageId,
speaker: probe.story.currentSpeaker,
text: probe.story.currentText,
memory: probe.story.firstBattleNarrativeMemory
});
if (pageId === 'first-victory-guan-yu') {
await page.screenshot({
path: `${screenshotDir}/rc-first-victory-memory.png`,
fullPage: true
});
}
}
}
if (probe.activeScenes.includes('CampScene')) { if (probe.activeScenes.includes('CampScene')) {
await waitForCamp(page); await waitForCamp(page);
return { lastStory, rewardDisplays: [...rewardDisplays.values()] }; return {
lastStory,
rewardDisplays: [...rewardDisplays.values()],
narrativeMemories: [...narrativeMemories.values()]
};
} }
await page.keyboard.press('Space'); await page.keyboard.press('Space');
await page.waitForTimeout(240); await page.waitForTimeout(240);
} }
await waitForCamp(page); await waitForCamp(page);
return { lastStory, rewardDisplays: [...rewardDisplays.values()] }; return {
lastStory,
rewardDisplays: [...rewardDisplays.values()],
narrativeMemories: [...narrativeMemories.values()]
};
} }
async function advanceStoryUntilEnding(page) { async function advanceStoryUntilEnding(page) {

View File

@@ -0,0 +1,299 @@
import type { StoryPage } from './scenario';
import {
sortieOrderCheckIdsByOrder,
sortieOrderDisplayLabel,
sortieOrderIds,
type CampaignSortieOrderId,
type CampaignSortieOrderProgressId,
type CampaignSortieOrderProgressSnapshot
} from './sortieOrders';
export const firstBattleNarrativeBattleId = 'first-battle-zhuo-commandery';
export const firstBattleVolunteerPromiseEventKey = 'first-engagement';
export const firstBattleVolunteerPromiseText =
'젊은 의병 · 말씀대로 곁의 동료와 보조를 맞추겠습니다.';
const victoryMemoryPageIds = [
'first-victory-guan-yu',
'first-victory-zhang-fei'
] as const;
const missedCriterionLabels: Record<Exclude<CampaignSortieOrderProgressId, 'victory'>, string> = {
'elite-grade': '전투 평가',
'elite-survival': '전원 생존',
'mobile-quick': '제한 턴',
'mobile-breakthrough': '돌파·의도 파훼',
'siege-objective': '추가 목표',
'siege-front': '전열 기여',
'siege-support': '후원 기여'
};
export type FirstBattleNarrativeReport = {
battleId?: unknown;
outcome?: unknown;
turnNumber?: unknown;
mvp?: {
unitId?: unknown;
name?: unknown;
};
sortieOrder?: {
orderId?: unknown;
achieved?: unknown;
progress?: readonly {
id?: unknown;
achieved?: unknown;
value?: unknown;
target?: unknown;
}[];
};
};
export type FirstBattleVictoryNarrativeFallbackReason =
| 'missing-report'
| 'battle-mismatch'
| 'non-victory'
| 'missing-order'
| 'invalid-order-progress';
export type FirstBattleVictoryNarrativeMemory = {
source: 'campaign' | 'fallback';
fallbackReason: FirstBattleVictoryNarrativeFallbackReason | null;
battleId: string | null;
orderId: CampaignSortieOrderId | null;
orderLabel: string | null;
achieved: boolean | null;
turnNumber: number | null;
mvp: { unitId: string; name: string } | null;
progress: CampaignSortieOrderProgressSnapshot[];
achievedCriterionCount: number;
criterionCount: number;
missedCriterionIds: CampaignSortieOrderProgressId[];
appliedPageIds: string[];
};
export type FirstBattleVictoryNarrativeResolution = {
pages: StoryPage[];
memory: FirstBattleVictoryNarrativeMemory;
};
export function volunteerPromiseLineForBattle(
battleId: string,
reassuredInCamp: boolean
) {
return battleId === firstBattleNarrativeBattleId && reassuredInCamp
? firstBattleVolunteerPromiseText
: undefined;
}
export function resolveFirstBattleVictoryNarrative(
sourcePages: readonly StoryPage[],
report?: FirstBattleNarrativeReport | null
): FirstBattleVictoryNarrativeResolution {
const pages = sourcePages.map((page) => ({ ...page }));
const fallback = fallbackReason(report);
if (fallback) {
return {
pages,
memory: fallbackMemory(report, fallback)
};
}
const order = report!.sortieOrder!;
const orderId = order.orderId as CampaignSortieOrderId;
const progress = normalizeProgress(orderId, order.progress!);
const criterionProgress = progress.filter((entry) => entry.id !== 'victory');
const missedCriterionIds = criterionProgress
.filter((entry) => !entry.achieved)
.map((entry) => entry.id);
const orderLabel = sortieOrderDisplayLabel(firstBattleNarrativeBattleId, orderId);
const turnNumber = positiveInteger(report!.turnNumber);
const mvp = normalizeMvp(report!.mvp);
const achieved = order.achieved === true;
const textByPageId = new Map<string, string>([
[
victoryMemoryPageIds[0],
guanYuVictoryMemoryText(orderLabel, achieved, missedCriterionIds, turnNumber)
],
[
victoryMemoryPageIds[1],
zhangFeiVictoryMemoryText(orderLabel, achieved, missedCriterionIds, turnNumber, mvp)
]
]);
const appliedPageIds: string[] = [];
pages.forEach((page) => {
const text = textByPageId.get(page.id);
if (text) {
page.text = text;
appliedPageIds.push(page.id);
}
});
return {
pages,
memory: {
source: 'campaign',
fallbackReason: null,
battleId: firstBattleNarrativeBattleId,
orderId,
orderLabel,
achieved,
turnNumber,
mvp,
progress,
achievedCriterionCount: criterionProgress.filter((entry) => entry.achieved).length,
criterionCount: criterionProgress.length,
missedCriterionIds,
appliedPageIds
}
};
}
function fallbackReason(
report?: FirstBattleNarrativeReport | null
): FirstBattleVictoryNarrativeFallbackReason | undefined {
if (!report) {
return 'missing-report';
}
if (report.battleId !== firstBattleNarrativeBattleId) {
return 'battle-mismatch';
}
if (report.outcome !== 'victory') {
return 'non-victory';
}
if (!report.sortieOrder) {
return 'missing-order';
}
if (
typeof report.sortieOrder.orderId !== 'string' ||
!(sortieOrderIds as readonly string[]).includes(report.sortieOrder.orderId) ||
typeof report.sortieOrder.achieved !== 'boolean' ||
!Array.isArray(report.sortieOrder.progress)
) {
return 'invalid-order-progress';
}
const orderId = report.sortieOrder.orderId as CampaignSortieOrderId;
const expectedIds = sortieOrderCheckIdsByOrder[orderId];
const progressIds = new Set(
report.sortieOrder.progress
.filter((entry) => typeof entry?.id === 'string' && typeof entry.achieved === 'boolean')
.map((entry) => entry.id)
);
return expectedIds.every((id) => progressIds.has(id))
? undefined
: 'invalid-order-progress';
}
function fallbackMemory(
report: FirstBattleNarrativeReport | null | undefined,
reason: FirstBattleVictoryNarrativeFallbackReason
): FirstBattleVictoryNarrativeMemory {
return {
source: 'fallback',
fallbackReason: reason,
battleId: typeof report?.battleId === 'string' ? report.battleId : null,
orderId: null,
orderLabel: null,
achieved: null,
turnNumber: positiveInteger(report?.turnNumber),
mvp: normalizeMvp(report?.mvp),
progress: [],
achievedCriterionCount: 0,
criterionCount: 0,
missedCriterionIds: [],
appliedPageIds: []
};
}
function normalizeProgress(
orderId: CampaignSortieOrderId,
rawProgress: NonNullable<NonNullable<FirstBattleNarrativeReport['sortieOrder']>['progress']>
) {
return sortieOrderCheckIdsByOrder[orderId].map((id) => {
const raw = rawProgress.find((entry) => entry?.id === id)!;
return {
id,
achieved: raw.achieved === true,
value: nonNegativeNumber(raw.value),
target: nonNegativeNumber(raw.target)
};
});
}
function normalizeMvp(raw: FirstBattleNarrativeReport['mvp']) {
const name = typeof raw?.name === 'string'
? raw.name.trim().replace(/\s+/g, ' ').slice(0, 12)
: '';
if (!name) {
return null;
}
const unitId = typeof raw?.unitId === 'string'
? raw.unitId.trim().slice(0, 80)
: '';
return { unitId, name };
}
function positiveInteger(value: unknown) {
const number = typeof value === 'number' ? value : Number.NaN;
return Number.isFinite(number) && number > 0
? Math.floor(number)
: null;
}
function nonNegativeNumber(value: unknown) {
const number = typeof value === 'number' ? value : Number.NaN;
return Number.isFinite(number) && number >= 0 ? number : 0;
}
function missedCriterionSummary(ids: CampaignSortieOrderProgressId[]) {
const firstId = ids.find((id): id is Exclude<CampaignSortieOrderProgressId, 'victory'> => (
id !== 'victory'
));
if (!firstId) {
return '전투의 완성도';
}
const additionalCount = ids.filter((id) => id !== 'victory').length - 1;
return additionalCount > 0
? `${missedCriterionLabels[firstId]}${additionalCount}가지`
: missedCriterionLabels[firstId];
}
function guanYuVictoryMemoryText(
orderLabel: string,
achieved: boolean,
missedIds: CampaignSortieOrderProgressId[],
turnNumber: number | null
) {
if (achieved) {
const record = turnNumber
? `${turnNumber}턴의 전황도 정리했습니다.`
: '모두 함께 돌아온 것이 오늘의 가장 큰 전공입니다.';
return `${orderLabel} 군령의 조건을 모두 지켰습니다. ${record}`;
}
const record = turnNumber
? `${turnNumber}턴의 기록을 다음 출전에서 보완하겠습니다.`
: '다음 출전에서는 그 약속부터 바로 세우겠습니다.';
return `${orderLabel} 군령으로 승리했지만 남은 과제는 ${missedCriterionSummary(missedIds)}입니다. ${record}`;
}
function zhangFeiVictoryMemoryText(
orderLabel: string,
achieved: boolean,
missedIds: CampaignSortieOrderProgressId[],
turnNumber: number | null,
mvp: { unitId: string; name: string } | null
) {
const mvpLine = mvp && mvp.unitId !== 'zhang-fei'
? `전공의 주인공은 ${mvp.name}요!`
: '전공은 셋 모두의 몫이오!';
if (achieved) {
const turnLine = turnNumber ? `${turnNumber}턴 만에 길을 열었고, ` : '';
return `하하, ${orderLabel} 군령까지 완수했소! ${turnLine}${mvpLine}`;
}
const subject = turnNumber
? `${turnNumber}턴의 전공에`
: mvp && mvp.unitId !== 'zhang-fei'
? `${mvp.name}의 전공에`
: '우리의 전공에';
return `이기긴 했지만 ${orderLabel} 군령은 놓쳤구려. ${subject} ${missedCriterionSummary(missedIds)}까지 더해 다음에는 약속도 지키겠소!`;
}

View File

@@ -285,6 +285,13 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD
textureKey: 'unit-liu-bei', textureKey: 'unit-liu-bei',
text: '두려움을 숨길 필요는 없네. 곁의 동료와 보조를 맞추고 위험하면 신호하게. 혼자 싸우게 두지 않겠네.' text: '두려움을 숨길 필요는 없네. 곁의 동료와 보조를 맞추고 위험하면 신호하게. 혼자 싸우게 두지 않겠네.'
} }
],
repeatDialogue: [
{
speaker: '젊은 의병',
textureKey: 'unit-shu-infantry',
text: '아직 떨리지만, 곁의 동료와 보조를 맞추겠습니다. 혼자 싸우지 않겠습니다.'
}
] ]
} }
]; ];

View File

@@ -116,6 +116,10 @@ import {
sortieRecommendationDisplayUnitIds, sortieRecommendationDisplayUnitIds,
type SortieRecommendationOutcome type SortieRecommendationOutcome
} from '../data/sortieRecommendationOutcome'; } from '../data/sortieRecommendationOutcome';
import {
firstBattleVolunteerPromiseEventKey,
volunteerPromiseLineForBattle
} from '../data/firstBattleNarrativeMemory';
import { import {
evaluateSortieOrder, evaluateSortieOrder,
evaluateSortieOrderProgress, evaluateSortieOrderProgress,
@@ -3878,6 +3882,7 @@ export class BattleScene extends Phaser.Scene {
private launchSortieResonanceBondId?: string; private launchSortieResonanceBondId?: string;
private launchSortieRecommendation?: CampaignSortieRecommendationSnapshot; private launchSortieRecommendation?: CampaignSortieRecommendationSnapshot;
private firstBattlePreparation: FirstBattlePreparationState = { ...emptyFirstBattlePreparationState }; private firstBattlePreparation: FirstBattlePreparationState = { ...emptyFirstBattlePreparationState };
private prologueVolunteerReassured = false;
private coreResonancePursuitAttempts = 0; private coreResonancePursuitAttempts = 0;
private coreResonancePursuitSuccesses = 0; private coreResonancePursuitSuccesses = 0;
private coreResonancePursuitFailures = 0; private coreResonancePursuitFailures = 0;
@@ -3982,6 +3987,9 @@ export class BattleScene extends Phaser.Scene {
this.firstBattleTutorialSkipButton = undefined; this.firstBattleTutorialSkipButton = undefined;
const campaign = getCampaignState(); const campaign = getCampaignState();
this.firstBattlePreparation = this.resolveFirstBattlePreparation(campaign); this.firstBattlePreparation = this.resolveFirstBattlePreparation(campaign);
this.prologueVolunteerReassured = campaign.completedTutorialIds.includes(
prologueMilitiaCampCampaignTutorialIds.reassureVolunteer
);
const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id
? campaign.sortieOrderSelection.orderId ? campaign.sortieOrderSelection.orderId
: undefined; : undefined;
@@ -12168,10 +12176,7 @@ export class BattleScene extends Phaser.Scene {
return; return;
} }
this.triggerBattleEvent('first-engagement', '첫 교전', [ this.triggerFirstEngagementEvent(attacker, target);
`${attacker.name}${target.name}을 공격합니다.`,
'공격 후 경험치와 장비 경험치가 전투창에서 정산됩니다.'
], { playCue: false });
this.phase = 'animating'; this.phase = 'animating';
const result = this.resolveCombatAction(attacker, target, action, false, usable); const result = this.resolveCombatAction(attacker, target, action, false, usable);
this.clearMarkers(); this.clearMarkers();
@@ -12262,6 +12267,18 @@ export class BattleScene extends Phaser.Scene {
return parts.join(' ') || '보정 없음'; return parts.join(' ') || '보정 없음';
} }
private triggerFirstEngagementEvent(attacker: UnitData, target: UnitData) {
const volunteerPromiseLine = volunteerPromiseLineForBattle(
battleScenario.id,
this.prologueVolunteerReassured
);
this.triggerBattleEvent(firstBattleVolunteerPromiseEventKey, '첫 교전', [
`${attacker.name} · ${target.name} 공격을 시작합니다.`,
'공격 후 경험치와 장비 경험치가 전투창에서 정산됩니다.',
...(volunteerPromiseLine ? [volunteerPromiseLine] : [])
], { playCue: false });
}
private battleBuffShortText(buff: BattleBuffState) { private battleBuffShortText(buff: BattleBuffState) {
const parts = [ const parts = [
buff.attackBonus > 0 ? `공+${buff.attackBonus}` : '', buff.attackBonus > 0 ? `공+${buff.attackBonus}` : '',
@@ -28790,6 +28807,31 @@ export class BattleScene extends Phaser.Scene {
}; };
} }
private firstBattleVolunteerPromiseDebugState() {
const line = volunteerPromiseLineForBattle(
battleScenario.id,
this.prologueVolunteerReassured
);
const eventState = this.triggeredBattleEvents.has(firstBattleVolunteerPromiseEventKey)
? 'completed'
: this.activeBattleEvent?.key === firstBattleVolunteerPromiseEventKey
? 'active'
: this.battleEventQueue.some((event) => event.key === firstBattleVolunteerPromiseEventKey)
? 'queued'
: 'waiting';
return {
completedInCamp: this.prologueVolunteerReassured,
eligible: Boolean(line),
eventKey: firstBattleVolunteerPromiseEventKey,
line: line ?? null,
eventState,
bannerBounds:
this.activeBattleEvent?.key === firstBattleVolunteerPromiseEventKey
? this.gameObjectCollectionBoundsDebug(this.battleEventObjects.slice(1))
: null
};
}
private firstBattleTutorialDebugState() { private firstBattleTutorialDebugState() {
const path = this.firstBattleTutorialPath; const path = this.firstBattleTutorialPath;
return { return {
@@ -28914,6 +28956,9 @@ export class BattleScene extends Phaser.Scene {
: null, : null,
battleHud: this.battleHudDebugState(), battleHud: this.battleHudDebugState(),
firstBattlePreparation: this.firstBattlePreparationDebugState(), firstBattlePreparation: this.firstBattlePreparationDebugState(),
firstBattleNarrativeMemory: {
volunteerPromise: this.firstBattleVolunteerPromiseDebugState()
},
firstBattleTutorial: this.firstBattleTutorialDebugState(), firstBattleTutorial: this.firstBattleTutorialDebugState(),
combatCutIn: combatCutInDebug, combatCutIn: combatCutInDebug,
combatCutInStage: combatCutInDebug, combatCutInStage: combatCutInDebug,
@@ -29305,6 +29350,23 @@ export class BattleScene extends Phaser.Scene {
return battleUnits.find((unit) => unit.id === unitId); return battleUnits.find((unit) => unit.id === unitId);
} }
debugTriggerFirstEngagementEvent() {
if (
!this.debugToolsEnabled() ||
this.battleOutcome ||
this.isBattleEventKnown(firstBattleVolunteerPromiseEventKey)
) {
return false;
}
const attacker = battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0);
const target = battleUnits.find((unit) => unit.faction === 'enemy' && unit.hp > 0);
if (!attacker || !target) {
return false;
}
this.triggerFirstEngagementEvent(attacker, target);
return this.isBattleEventKnown(firstBattleVolunteerPromiseEventKey);
}
debugBattleUnits() { debugBattleUnits() {
return this.debugToolsEnabled() ? battleUnits : undefined; return this.debugToolsEnabled() ? battleUnits : undefined;
} }

View File

@@ -147,6 +147,10 @@ const objectiveTutorialIds: Record<PrologueMilitiaCampRequiredObjectiveId, Campa
'inspect-arms': prologueMilitiaCampCampaignTutorialIds.inspectArms 'inspect-arms': prologueMilitiaCampCampaignTutorialIds.inspectArms
}; };
const optionalDialogueTutorialIds: Partial<Record<string, CampaignTutorialId>> = {
'nervous-volunteer': prologueMilitiaCampCampaignTutorialIds.reassureVolunteer
};
function campDialoguePortraitEntries() { function campDialoguePortraitEntries() {
return campDialoguePortraitKeys return campDialoguePortraitKeys
.map((portraitKey) => portraitAssetEntryForStoryContext( .map((portraitKey) => portraitAssetEntryForStoryContext(
@@ -408,6 +412,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
})) ?? [] })) ?? []
}; };
}), }),
optionalDialogues: Object.entries(optionalDialogueTutorialIds).map(([npcId, tutorialId]) => ({
npcId,
tutorialId,
completed: tutorialId ? hasCompletedCampaignTutorial(tutorialId) : false
})),
exitUnlocked: this.allRequiredObjectivesComplete(), exitUnlocked: this.allRequiredObjectivesComplete(),
npcs: Array.from(this.npcViews.values()).map(({ definition, sprite }) => ({ npcs: Array.from(this.npcViews.values()).map(({ definition, sprite }) => ({
id: definition.id, id: definition.id,
@@ -1538,9 +1547,12 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
return; return;
} }
const optionalTutorialId = optionalDialogueTutorialIds[view.definition.id];
const completed = view.definition.objectiveId const completed = view.definition.objectiveId
? this.isObjectiveComplete(view.definition.objectiveId) ? this.isObjectiveComplete(view.definition.objectiveId)
: false; : optionalTutorialId
? hasCompletedCampaignTutorial(optionalTutorialId)
: false;
const dialogue = completed && view.definition.repeatDialogue?.length const dialogue = completed && view.definition.repeatDialogue?.length
? view.definition.repeatDialogue ? view.definition.repeatDialogue
: view.definition.dialogue; : view.definition.dialogue;
@@ -1548,6 +1560,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
if (view.definition.objectiveId && !completed) { if (view.definition.objectiveId && !completed) {
this.completeObjective(view.definition.objectiveId); this.completeObjective(view.definition.objectiveId);
} }
if (optionalTutorialId && !completed) {
completeCampaignTutorial(optionalTutorialId);
this.showCompletionToast('젊은 의병이 약속을 기억합니다.');
}
}, view.definition.id); }, view.definition.id);
} }

View File

@@ -32,6 +32,11 @@ import {
resolveCampaignPresentationSoundscape, resolveCampaignPresentationSoundscape,
type CampaignPresentationStage type CampaignPresentationStage
} from '../data/campaignPresentationProfiles'; } from '../data/campaignPresentationProfiles';
import {
firstBattleNarrativeBattleId,
resolveFirstBattleVictoryNarrative,
type FirstBattleVictoryNarrativeMemory
} from '../data/firstBattleNarrativeMemory';
import { import {
ensureUnitAnimations, ensureUnitAnimations,
loadUnitBaseSheets, loadUnitBaseSheets,
@@ -216,6 +221,7 @@ export class StoryScene extends Phaser.Scene {
private storyAssetFailures = new Set<string>(); private storyAssetFailures = new Set<string>();
private ownedStoryTextureKeys = new Set<string>(); private ownedStoryTextureKeys = new Set<string>();
private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay(); private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay();
private firstBattleNarrativeMemory?: FirstBattleVictoryNarrativeMemory;
private visualMotionReduced = false; private visualMotionReduced = false;
private storyEnvironmentProfile?: StoryEnvironmentProfile; private storyEnvironmentProfile?: StoryEnvironmentProfile;
private storyEnvironmentBackgroundKey?: string; private storyEnvironmentBackgroundKey?: string;
@@ -231,12 +237,20 @@ export class StoryScene extends Phaser.Scene {
} }
init(data?: StorySceneData) { init(data?: StorySceneData) {
this.pages = data?.pages?.length ? data.pages : prologueOpeningPages(); const sourcePages = data?.pages?.length ? data.pages : prologueOpeningPages();
this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages);
this.nextScene = data?.nextScene ?? 'PrologueVillageScene'; this.nextScene = data?.nextScene ?? 'PrologueVillageScene';
this.nextSceneData = data?.nextSceneData; this.nextSceneData = data?.nextSceneData;
this.presentationBattleId = data?.presentationBattleId; this.presentationBattleId = data?.presentationBattleId;
this.presentationStage = data?.presentationStage ?? 'story'; this.presentationStage = data?.presentationStage ?? 'story';
const firstBattleAftermath = (
this.presentationBattleId === firstBattleNarrativeBattleId &&
this.presentationStage === 'aftermath'
)
? resolveFirstBattleVictoryNarrative(sourcePages, getFirstBattleReport())
: undefined;
this.pages = firstBattleAftermath?.pages ?? sourcePages;
this.firstBattleNarrativeMemory = firstBattleAftermath?.memory;
this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages);
this.presentationWash = undefined; this.presentationWash = undefined;
this.pageIndex = 0; this.pageIndex = 0;
this.transitioning = false; this.transitioning = false;
@@ -279,6 +293,8 @@ export class StoryScene extends Phaser.Scene {
pageIndex: this.pageIndex, pageIndex: this.pageIndex,
totalPages: this.pages.length, totalPages: this.pages.length,
currentPageId: page?.id, currentPageId: page?.id,
currentSpeaker: page?.speaker ?? null,
currentText: this.bodyText?.text ?? page?.text ?? '',
requestedBackground: page?.background, requestedBackground: page?.background,
background: page ? this.pageBackgroundKey(page) : undefined, background: page ? this.pageBackgroundKey(page) : undefined,
backgroundReady: page ? this.textures.exists(this.pageBackgroundKey(page)) : false, backgroundReady: page ? this.textures.exists(this.pageBackgroundKey(page)) : false,
@@ -319,6 +335,20 @@ export class StoryScene extends Phaser.Scene {
...this.rewardDisplay, ...this.rewardDisplay,
items: this.rewardDisplay.items.map((item) => ({ ...item, icon: { ...item.icon } })) items: this.rewardDisplay.items.map((item) => ({ ...item, icon: { ...item.icon } }))
}, },
firstBattleNarrativeMemory: this.firstBattleNarrativeMemory
? {
...this.firstBattleNarrativeMemory,
mvp: this.firstBattleNarrativeMemory.mvp
? { ...this.firstBattleNarrativeMemory.mvp }
: null,
progress: this.firstBattleNarrativeMemory.progress.map((entry) => ({ ...entry })),
missedCriterionIds: [...this.firstBattleNarrativeMemory.missedCriterionIds],
appliedPageIds: [...this.firstBattleNarrativeMemory.appliedPageIds],
currentPageApplied: Boolean(
page && this.firstBattleNarrativeMemory.appliedPageIds.includes(page.id)
)
}
: null,
environment: this.storyEnvironmentDebugState(), environment: this.storyEnvironmentDebugState(),
presentation: this.storyPresentationDebugState(), presentation: this.storyPresentationDebugState(),
assetStreaming: { assetStreaming: {

View File

@@ -479,6 +479,7 @@ export const prologueMilitiaCampCampaignTutorialIds = {
reviewScoutReport: 'prologue-camp-review-scout-report', reviewScoutReport: 'prologue-camp-review-scout-report',
readyVanguard: 'prologue-camp-ready-vanguard', readyVanguard: 'prologue-camp-ready-vanguard',
inspectArms: 'prologue-camp-inspect-arms', inspectArms: 'prologue-camp-inspect-arms',
reassureVolunteer: 'prologue-camp-reassure-volunteer',
complete: 'prologue-camp-complete' complete: 'prologue-camp-complete'
} as const; } as const;