feat: carry camp promises into first victory
This commit is contained in:
@@ -95,9 +95,27 @@ RPG로 다듬는다.
|
|||||||
때만 사이드 패널에서 제한을 설명한다.
|
때만 사이드 패널에서 제한을 설명한다.
|
||||||
- 필수 점검과 군령 반응의 중복 대사를 줄여 진행 입력을 약 7회 줄인다.
|
- 필수 점검과 군령 반응의 중복 대사를 줄여 진행 입력을 약 7회 줄인다.
|
||||||
|
|
||||||
다음 초반 묶음은 젊은 의병의 격려가 첫 전투 중 짧은 반응으로 돌아오고,
|
### 완료한 3차 묶음: 막사의 약속과 첫 승리 회고
|
||||||
첫 승리 뒤 막사 인물들이 플레이어가 고른 군령과 전투 결과를 서로 다르게
|
|
||||||
기억하도록 연결하는 것을 우선한다.
|
- 젊은 의병과 끝까지 대화하면 선택적 약속을 저장하되, 필수 점검 수나
|
||||||
|
출전 조건에는 포함하지 않는다.
|
||||||
|
- 약속한 첫 전투의 실제 첫 공격에서 기존 자동 소멸 안내의 세 번째 줄로
|
||||||
|
의병의 반응을 회수한다. 새 확인창이나 추가 입력은 만들지 않는다.
|
||||||
|
- 대화하지 않았을 때는 기존 두 줄 안내를 유지하고, 이후 전투에는 약속이
|
||||||
|
나타나지 않는다.
|
||||||
|
- 첫 승리 뒤 관우와 장비는 저장된 실제 군령, 달성 여부, 실패 조건, 턴과
|
||||||
|
전공을 서로 다른 말투로 회고한다.
|
||||||
|
- 관우는 조건과 다음 보완점을 분석하고, 장비는 달성의 기세와 사람의
|
||||||
|
전공을 강조한다.
|
||||||
|
- 회고 문장은 저장 데이터에서 매번 파생하고 공유 이야기 원본을 바꾸지
|
||||||
|
않는다. 오래된 저장처럼 군령 결과가 없거나 불완전하면 기존 대사를
|
||||||
|
그대로 사용한다.
|
||||||
|
- 세 군령의 달성·미달 여섯 조합, 선택 대화의 저장·재개, 첫 교전 중복
|
||||||
|
방지, 이후 전투 누출 방지와 1920x1080 배치를 자동 검증한다.
|
||||||
|
|
||||||
|
다음 초반 묶음은 첫 승리에서 드러난 군령의 남은 과제가 첫 군영의 동료
|
||||||
|
대화와 다음 편성 제안으로 이어지게 해, 회고가 설명으로 끝나지 않고 다음
|
||||||
|
준비 행동을 바꾸도록 연결하는 것을 우선한다.
|
||||||
|
|
||||||
## 장기 개선 순서
|
## 장기 개선 순서
|
||||||
|
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
299
src/game/data/firstBattleNarrativeMemory.ts
Normal file
299
src/game/data/firstBattleNarrativeMemory.ts
Normal 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)}까지 더해 다음에는 약속도 지키겠소!`;
|
||||||
|
}
|
||||||
@@ -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: '아직 떨리지만, 곁의 동료와 보조를 맞추겠습니다. 혼자 싸우지 않겠습니다.'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user