From 0c16ff3c0377f721a41b2fa87c1c1e0112bf21a4 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 27 Jul 2026 05:01:17 +0900 Subject: [PATCH] feat: carry camp promises into first victory --- docs/game-design-improvement-goal.md | 24 +- scripts/verify-audiovisual-feedback.mjs | 5 +- scripts/verify-prologue-village-browser.mjs | 166 ++++++++++- scripts/verify-prologue-village-data.mjs | 183 +++++++++++- scripts/verify-release-candidate.mjs | 104 ++++++- src/game/data/firstBattleNarrativeMemory.ts | 299 ++++++++++++++++++++ src/game/data/prologueMilitiaCamp.ts | 7 + src/game/scenes/BattleScene.ts | 70 ++++- src/game/scenes/PrologueMilitiaCampScene.ts | 18 +- src/game/scenes/StoryScene.ts | 34 ++- src/game/state/campaignState.ts | 1 + 11 files changed, 888 insertions(+), 23 deletions(-) create mode 100644 src/game/data/firstBattleNarrativeMemory.ts diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index 14d8cc6..8641f40 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -95,9 +95,27 @@ RPG로 다듬는다. 때만 사이드 패널에서 제한을 설명한다. - 필수 점검과 군령 반응의 중복 대사를 줄여 진행 입력을 약 7회 줄인다. -다음 초반 묶음은 젊은 의병의 격려가 첫 전투 중 짧은 반응으로 돌아오고, -첫 승리 뒤 막사 인물들이 플레이어가 고른 군령과 전투 결과를 서로 다르게 -기억하도록 연결하는 것을 우선한다. +### 완료한 3차 묶음: 막사의 약속과 첫 승리 회고 + +- 젊은 의병과 끝까지 대화하면 선택적 약속을 저장하되, 필수 점검 수나 + 출전 조건에는 포함하지 않는다. +- 약속한 첫 전투의 실제 첫 공격에서 기존 자동 소멸 안내의 세 번째 줄로 + 의병의 반응을 회수한다. 새 확인창이나 추가 입력은 만들지 않는다. +- 대화하지 않았을 때는 기존 두 줄 안내를 유지하고, 이후 전투에는 약속이 + 나타나지 않는다. +- 첫 승리 뒤 관우와 장비는 저장된 실제 군령, 달성 여부, 실패 조건, 턴과 + 전공을 서로 다른 말투로 회고한다. +- 관우는 조건과 다음 보완점을 분석하고, 장비는 달성의 기세와 사람의 + 전공을 강조한다. +- 회고 문장은 저장 데이터에서 매번 파생하고 공유 이야기 원본을 바꾸지 + 않는다. 오래된 저장처럼 군령 결과가 없거나 불완전하면 기존 대사를 + 그대로 사용한다. +- 세 군령의 달성·미달 여섯 조합, 선택 대화의 저장·재개, 첫 교전 중복 + 방지, 이후 전투 누출 방지와 1920x1080 배치를 자동 검증한다. + +다음 초반 묶음은 첫 승리에서 드러난 군령의 남은 과제가 첫 군영의 동료 +대화와 다음 편성 제안으로 이어지게 해, 회고가 설명으로 끝나지 않고 다음 +준비 행동을 바꾸도록 연결하는 것을 우선한다. ## 장기 개선 순서 diff --git a/scripts/verify-audiovisual-feedback.mjs b/scripts/verify-audiovisual-feedback.mjs index 2831996..fa9d044 100644 --- a/scripts/verify-audiovisual-feedback.mjs +++ b/scripts/verify-audiovisual-feedback.mjs @@ -95,7 +95,10 @@ assert.match(deploymentPanel, /'준비 중 · 누르면 자동 시작'/); assert.match(deploymentPanel, /combatAssetsReady\s*\? '전투 시작'/s); 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'); assert.match(triggerBattleEvent, /options\.playCue !== false/); diff --git a/scripts/verify-prologue-village-browser.mjs b/scripts/verify-prologue-village-browser.mjs index af08349..924a0b4 100644 --- a/scripts/verify-prologue-village-browser.mjs +++ b/scripts/verify-prologue-village-browser.mjs @@ -380,6 +380,11 @@ try { { 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.dialogue.active, @@ -475,6 +480,48 @@ try { '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 waitForDebugApi(page); await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene')); @@ -496,6 +543,22 @@ try { false, '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, 'zhang-fei'); @@ -711,8 +774,43 @@ try { count: 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'); + 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(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene?.saveBattleState?.(1); @@ -730,6 +828,20 @@ try { 'Saving and resuming the first battle must not grant the preparation salve twice.' ); 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(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); @@ -749,6 +861,27 @@ try { secondBattle.firstBattlePreparation.callbacks.every(({ completed }) => completed === false), '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 page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); @@ -774,7 +907,8 @@ try { console.log( `Verified the playable prologue village and militia camp at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + `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 { await browser?.close(); @@ -808,10 +942,19 @@ async function clickLegacyTitleControl(page, x, y) { } async function waitForStoryReady(page) { - await page.waitForFunction(() => { - const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.(); - return window.__HEROS_DEBUG__?.activeScenes?.().includes('StoryScene') && story?.ready === true; - }, undefined, { timeout: 90000 }); + try { + await page.waitForFunction(() => { + const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.(); + 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) { @@ -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) { const viewport = await page.evaluate(() => { const canvas = document.querySelector('canvas'); diff --git a/scripts/verify-prologue-village-data.mjs b/scripts/verify-prologue-village-data.mjs index 48ac417..9dfaf88 100644 --- a/scripts/verify-prologue-village-data.mjs +++ b/scripts/verify-prologue-village-data.mjs @@ -27,6 +27,7 @@ const expectedCampMarkerIds = [ 'prologue-camp-review-scout-report', 'prologue-camp-ready-vanguard', 'prologue-camp-inspect-arms', + 'prologue-camp-reassure-volunteer', 'prologue-camp-complete' ]; const expectedFirstCommandOrderIds = ['elite', 'mobile', 'siege']; @@ -40,6 +41,8 @@ const server = await createServer({ try { const village = await server.ssrLoadModule('/src/game/data/prologueVillage.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 campaign = await server.ssrLoadModule('/src/game/state/campaignState.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', 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( !JSON.stringify(campNpcs).includes('피란'), 'militia camp dialogue must use the established 피난 terminology consistently', @@ -302,6 +313,12 @@ try { 'prologue-camp must be recognized by campaign progress summaries', failures ); + verifyFirstBattleNarrativeMemory( + narrativeMemory, + scenario.firstBattleVictoryPages, + sortieOrders, + failures + ); if (failures.length > 0) { throw new Error( @@ -313,7 +330,8 @@ try { console.log( `Verified ${npcs.length} village NPCs, ${campNpcs.length} militia camp NPCs, ` + `${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 { 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) { assert( new Set(npcs.map((npc) => npc.id)).size === npcs.length, diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 3a72aa1..cc8395b 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -1277,12 +1277,14 @@ try { const originalPlayCombatCutIn = scene.playCombatCutIn; const originalApplyDefeatedStyle = scene.applyDefeatedStyle; const originalResolveCounterAttack = scene.resolveCounterAttack; + const originalTriggerBattleEvent = scene.triggerBattleEvent; const originalCombatPresentationMode = scene.combatPresentationMode; const methodOwnership = { resolveCombatAction: Object.prototype.hasOwnProperty.call(scene, 'resolveCombatAction'), playCombatCutIn: Object.prototype.hasOwnProperty.call(scene, 'playCombatCutIn'), 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 counterResult = null; @@ -1295,6 +1297,7 @@ try { let counterProduced = false; const cutInOrder = []; const defeatApplications = []; + let firstEngagementEvent = null; const restoreMethod = (name, original) => { if (methodOwnership[name]) { @@ -1349,6 +1352,12 @@ try { counterProduced = counterProduced || Boolean(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 { scene.phase = 'targeting'; @@ -1371,13 +1380,18 @@ try { visibleAfterWrapper: viewState(), counterCalls, counterProduced, - cutInOrder + cutInOrder, + firstEngagementEvent, + firstBattleNarrativeMemory: clone( + scene.getDebugState().firstBattleNarrativeMemory + ) }; } finally { restoreMethod('resolveCombatAction', originalResolveCombatAction); restoreMethod('playCombatCutIn', originalPlayCombatCutIn); restoreMethod('applyDefeatedStyle', originalApplyDefeatedStyle); restoreMethod('resolveCounterAttack', originalResolveCounterAttack); + restoreMethod('triggerBattleEvent', originalTriggerBattleEvent); scene.combatPresentationMode = originalCombatPresentationMode; if (hadOwnRollPercent) { scene.rollPercent = originalRollPercent; @@ -1517,6 +1531,16 @@ try { 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)}` ); + 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; assert( productionSurvivor.result?.hit === true && @@ -2192,8 +2216,9 @@ try { firstSortieOrder?.open === false && firstSortieOrder?.toggleButtonBounds && firstSortieOrderResult?.orderId === 'elite' && + firstSortieOrderResult?.achieved === true && 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( resultProbe.resultTexts.includes(`군자금 +${expectedFirstDisplayedRewardGold}`), @@ -2349,7 +2374,10 @@ try { assert( directFirstAftermath?.pageIndex === 0 && 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)}` ); await page.keyboard.press('Space'); @@ -2373,6 +2401,16 @@ try { 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 })}` ); + 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); assert( firstCampTransition.lastStory?.isLastPage === true && @@ -2397,6 +2435,30 @@ try { const firstRewardDisplaysByPage = Object.fromEntries( 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 expectedFirstStorySupplies = [ ...(persistedFirstRewards?.supplies ?? []), @@ -9432,6 +9494,7 @@ async function waitForCampAfterBattleResult(page) { let lastStory = null; const rewardDisplays = new Map(); + const narrativeMemories = new Map(); for (let i = 0; i < 48; i += 1) { const probe = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], @@ -9447,16 +9510,45 @@ async function waitForCampAfterBattleResult(page) { ) { 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')) { await waitForCamp(page); - return { lastStory, rewardDisplays: [...rewardDisplays.values()] }; + return { + lastStory, + rewardDisplays: [...rewardDisplays.values()], + narrativeMemories: [...narrativeMemories.values()] + }; } await page.keyboard.press('Space'); await page.waitForTimeout(240); } await waitForCamp(page); - return { lastStory, rewardDisplays: [...rewardDisplays.values()] }; + return { + lastStory, + rewardDisplays: [...rewardDisplays.values()], + narrativeMemories: [...narrativeMemories.values()] + }; } async function advanceStoryUntilEnding(page) { diff --git a/src/game/data/firstBattleNarrativeMemory.ts b/src/game/data/firstBattleNarrativeMemory.ts new file mode 100644 index 0000000..930fada --- /dev/null +++ b/src/game/data/firstBattleNarrativeMemory.ts @@ -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, 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([ + [ + 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['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 => ( + 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)}까지 더해 다음에는 약속도 지키겠소!`; +} diff --git a/src/game/data/prologueMilitiaCamp.ts b/src/game/data/prologueMilitiaCamp.ts index a9dfe9f..05d8efa 100644 --- a/src/game/data/prologueMilitiaCamp.ts +++ b/src/game/data/prologueMilitiaCamp.ts @@ -285,6 +285,13 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD textureKey: 'unit-liu-bei', text: '두려움을 숨길 필요는 없네. 곁의 동료와 보조를 맞추고 위험하면 신호하게. 혼자 싸우게 두지 않겠네.' } + ], + repeatDialogue: [ + { + speaker: '젊은 의병', + textureKey: 'unit-shu-infantry', + text: '아직 떨리지만, 곁의 동료와 보조를 맞추겠습니다. 혼자 싸우지 않겠습니다.' + } ] } ]; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index e5aee0e..d49a01f 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -116,6 +116,10 @@ import { sortieRecommendationDisplayUnitIds, type SortieRecommendationOutcome } from '../data/sortieRecommendationOutcome'; +import { + firstBattleVolunteerPromiseEventKey, + volunteerPromiseLineForBattle +} from '../data/firstBattleNarrativeMemory'; import { evaluateSortieOrder, evaluateSortieOrderProgress, @@ -3878,6 +3882,7 @@ export class BattleScene extends Phaser.Scene { private launchSortieResonanceBondId?: string; private launchSortieRecommendation?: CampaignSortieRecommendationSnapshot; private firstBattlePreparation: FirstBattlePreparationState = { ...emptyFirstBattlePreparationState }; + private prologueVolunteerReassured = false; private coreResonancePursuitAttempts = 0; private coreResonancePursuitSuccesses = 0; private coreResonancePursuitFailures = 0; @@ -3982,6 +3987,9 @@ export class BattleScene extends Phaser.Scene { this.firstBattleTutorialSkipButton = undefined; const campaign = getCampaignState(); this.firstBattlePreparation = this.resolveFirstBattlePreparation(campaign); + this.prologueVolunteerReassured = campaign.completedTutorialIds.includes( + prologueMilitiaCampCampaignTutorialIds.reassureVolunteer + ); const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id ? campaign.sortieOrderSelection.orderId : undefined; @@ -12168,10 +12176,7 @@ export class BattleScene extends Phaser.Scene { return; } - this.triggerBattleEvent('first-engagement', '첫 교전', [ - `${attacker.name}이 ${target.name}을 공격합니다.`, - '공격 후 경험치와 장비 경험치가 전투창에서 정산됩니다.' - ], { playCue: false }); + this.triggerFirstEngagementEvent(attacker, target); this.phase = 'animating'; const result = this.resolveCombatAction(attacker, target, action, false, usable); this.clearMarkers(); @@ -12262,6 +12267,18 @@ export class BattleScene extends Phaser.Scene { 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) { const parts = [ 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() { const path = this.firstBattleTutorialPath; return { @@ -28914,6 +28956,9 @@ export class BattleScene extends Phaser.Scene { : null, battleHud: this.battleHudDebugState(), firstBattlePreparation: this.firstBattlePreparationDebugState(), + firstBattleNarrativeMemory: { + volunteerPromise: this.firstBattleVolunteerPromiseDebugState() + }, firstBattleTutorial: this.firstBattleTutorialDebugState(), combatCutIn: combatCutInDebug, combatCutInStage: combatCutInDebug, @@ -29305,6 +29350,23 @@ export class BattleScene extends Phaser.Scene { 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() { return this.debugToolsEnabled() ? battleUnits : undefined; } diff --git a/src/game/scenes/PrologueMilitiaCampScene.ts b/src/game/scenes/PrologueMilitiaCampScene.ts index 6b75bdb..f32755c 100644 --- a/src/game/scenes/PrologueMilitiaCampScene.ts +++ b/src/game/scenes/PrologueMilitiaCampScene.ts @@ -147,6 +147,10 @@ const objectiveTutorialIds: Record> = { + 'nervous-volunteer': prologueMilitiaCampCampaignTutorialIds.reassureVolunteer +}; + function campDialoguePortraitEntries() { return campDialoguePortraitKeys .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(), npcs: Array.from(this.npcViews.values()).map(({ definition, sprite }) => ({ id: definition.id, @@ -1538,9 +1547,12 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { return; } + const optionalTutorialId = optionalDialogueTutorialIds[view.definition.id]; const completed = view.definition.objectiveId ? this.isObjectiveComplete(view.definition.objectiveId) - : false; + : optionalTutorialId + ? hasCompletedCampaignTutorial(optionalTutorialId) + : false; const dialogue = completed && view.definition.repeatDialogue?.length ? view.definition.repeatDialogue : view.definition.dialogue; @@ -1548,6 +1560,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { if (view.definition.objectiveId && !completed) { this.completeObjective(view.definition.objectiveId); } + if (optionalTutorialId && !completed) { + completeCampaignTutorial(optionalTutorialId); + this.showCompletionToast('젊은 의병이 약속을 기억합니다.'); + } }, view.definition.id); } diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 90ec631..ab4ef60 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -32,6 +32,11 @@ import { resolveCampaignPresentationSoundscape, type CampaignPresentationStage } from '../data/campaignPresentationProfiles'; +import { + firstBattleNarrativeBattleId, + resolveFirstBattleVictoryNarrative, + type FirstBattleVictoryNarrativeMemory +} from '../data/firstBattleNarrativeMemory'; import { ensureUnitAnimations, loadUnitBaseSheets, @@ -216,6 +221,7 @@ export class StoryScene extends Phaser.Scene { private storyAssetFailures = new Set(); private ownedStoryTextureKeys = new Set(); private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay(); + private firstBattleNarrativeMemory?: FirstBattleVictoryNarrativeMemory; private visualMotionReduced = false; private storyEnvironmentProfile?: StoryEnvironmentProfile; private storyEnvironmentBackgroundKey?: string; @@ -231,12 +237,20 @@ export class StoryScene extends Phaser.Scene { } init(data?: StorySceneData) { - this.pages = data?.pages?.length ? data.pages : prologueOpeningPages(); - this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages); + const sourcePages = data?.pages?.length ? data.pages : prologueOpeningPages(); this.nextScene = data?.nextScene ?? 'PrologueVillageScene'; this.nextSceneData = data?.nextSceneData; this.presentationBattleId = data?.presentationBattleId; 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.pageIndex = 0; this.transitioning = false; @@ -279,6 +293,8 @@ export class StoryScene extends Phaser.Scene { pageIndex: this.pageIndex, totalPages: this.pages.length, currentPageId: page?.id, + currentSpeaker: page?.speaker ?? null, + currentText: this.bodyText?.text ?? page?.text ?? '', requestedBackground: page?.background, background: page ? this.pageBackgroundKey(page) : undefined, backgroundReady: page ? this.textures.exists(this.pageBackgroundKey(page)) : false, @@ -319,6 +335,20 @@ export class StoryScene extends Phaser.Scene { ...this.rewardDisplay, 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(), presentation: this.storyPresentationDebugState(), assetStreaming: { diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index b428f07..7097802 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -479,6 +479,7 @@ export const prologueMilitiaCampCampaignTutorialIds = { reviewScoutReport: 'prologue-camp-review-scout-report', readyVanguard: 'prologue-camp-ready-vanguard', inspectArms: 'prologue-camp-inspect-arms', + reassureVolunteer: 'prologue-camp-reassure-volunteer', complete: 'prologue-camp-complete' } as const;