diff --git a/docs/visual-asset-prologue-exploration-report.md b/docs/visual-asset-prologue-exploration-report.md index 64a50bc..367a7f6 100644 --- a/docs/visual-asset-prologue-exploration-report.md +++ b/docs/visual-asset-prologue-exploration-report.md @@ -11,11 +11,12 @@ files. | --- | --- | --- | | Zhuo village and militia camp | `src/assets/images/exploration/prologue-village.webp`, `prologue-militia-camp.webp` | 1920×1080 opaque WebP | | Second-victory northern village and ferry | `src/assets/images/exploration/second-pursuit-village-ferry.webp` | 1920×1080 opaque WebP | +| Third-victory Guangzong forward sortie camp | `src/assets/images/exploration/third-guangzong-sortie-camp.webp` | 1920×1080 opaque WebP | | Exploration SD characters | `src/assets/images/exploration/characters/exploration-*.webp` | 3072×768 transparent WebP sprite sheets | | First-victory camp Jian Yong | `src/assets/images/exploration/characters/exploration-jian-yong.webp` | 3072×768 transparent WebP sprite sheet | | Prologue NPC dialogue portraits | `src/assets/images/portraits/zhuo-*-yellow-turban.webp`, `zou-jing-yellow-turban.webp` | 1254×1254 WebP | -The three backgrounds reserve the exact top, right, and bottom HUD regions used +The four backgrounds reserve the exact top, right, and bottom HUD regions used by the 1920×1080 desktop layout. Each playable portion was aligned to the existing collision and interaction coordinate system before export. @@ -52,9 +53,22 @@ Its final built-in generation request was: > text, UI, logo, watermark, modern object, copied character, or copyrighted > game design. -The generated master was converted to an opaque 1920×1080 WebP with -`scripts/build-exploration-background.py`; only the optimized runtime asset is -tracked. +The third-victory variant depicts a forward-deployment camp outside +Guangzong: a north gate, broad readable crossroads, an operations tent and map +table on the left, a loaded supply cart in the lower-left, and a rally and +weapon area on the right. Its final built-in generation request was: + +> Original late-Eastern-Han Guangzong forward-deployment camp for a desktop +> historical RPG, elevated three-quarter top-down camera, warm dusk light, +> north gate and clean crossroads, left operations tent with map table, +> lower-left loaded supply cart, and right rally and weapon area. Preserve +> dark, low-detail safe zones for the fixed top, right, and bottom HUD. No +> people, text, UI, logos, modern objects, copied characters, or copyrighted +> game art. + +The generated masters were converted to opaque 1920×1080 WebP runtime assets +with the project background builder or an equivalent loss-controlled FFmpeg +conversion; only the optimized runtime assets are tracked. ### Dialogue portraits diff --git a/scripts/verify-camp-reward-data.mjs b/scripts/verify-camp-reward-data.mjs index 872f732..687d9f9 100644 --- a/scripts/verify-camp-reward-data.mjs +++ b/scripts/verify-camp-reward-data.mjs @@ -20,6 +20,16 @@ try { await server.ssrLoadModule( '/src/game/data/secondBattleReliefExploration.ts' ); + const { + thirdCampCompanionDialogues, + thirdCampExplorationVisitId + } = await server.ssrLoadModule( + '/src/game/data/thirdCampExploration.ts' + ); + const { thirdBattleReturnCampStep } = + await server.ssrLoadModule( + '/src/game/data/thirdBattleReturnDialogue.ts' + ); const scenarioData = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { isCampaignStep } = await server.ssrLoadModule('/src/game/state/campaignState.ts'); const itemRewardArrays = collectItemRewardArrays(source); @@ -28,14 +38,22 @@ try { const campBattleIdKeys = new Set(campBattleIdEntries.map((entry) => entry.key)); validateCampBattleIdEntries(campBattleIdEntries, battleScenarios); const knownBondsById = collectKnownBondsById(battleScenarios, scenarioData); - const dialogueEvents = collectCampEvents(source, 'campDialogues'); + const dialogueEvents = + materializeCanonicalThirdCampCompanionDialogues( + collectCampEvents(source, 'campDialogues'), + thirdCampCompanionDialogues + ); const visitEvents = collectCampEvents(source, 'campVisits').map((event) => - materializeCanonicalSecondBattleReliefVisit( - materializeCanonicalFirstPursuitVisit( - event, - firstPursuitScoutVisitDefinition + materializeCanonicalThirdCampExplorationVisit( + materializeCanonicalSecondBattleReliefVisit( + materializeCanonicalFirstPursuitVisit( + event, + firstPursuitScoutVisitDefinition + ), + secondBattleReliefExplorationDefinition ), - secondBattleReliefExplorationDefinition + thirdCampExplorationVisitId, + thirdBattleReturnCampStep ) ); const campaignStepReferences = @@ -341,6 +359,40 @@ function collectCampEvents(text, arrayName) { })); } +function materializeCanonicalThirdCampCompanionDialogues( + events, + definitions +) { + const quote = (value) => + `'${String(value).replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`; + return events.flatMap((event) => { + if ( + !event.body.includes('...dialogue') || + !event.body.includes('choices: dialogue.choices') + ) { + return [event]; + } + return definitions.map((definition) => { + const choiceSource = definition.choices + .map( + (choice) => + `{ id: ${quote(choice.id)}, rewardExp: ${choice.rewardExp} }` + ) + .join(', '); + return { + ...event, + body: + `{ id: ${quote(definition.id)}, ` + + 'availableAfterBattleIds: [campBattleIds.third], ' + + `unitIds: [${definition.unitIds.map(quote).join(', ')}], ` + + `bondId: ${quote(definition.bondId)}, ` + + `rewardExp: ${definition.rewardExp}, ` + + `choices: [${choiceSource}] }` + }; + }); + }); +} + function materializeCanonicalFirstPursuitVisit(event, definition) { if (!event.body.includes('...firstPursuitScoutVisitDefinition')) { return event; @@ -390,6 +442,26 @@ function materializeCanonicalSecondBattleReliefVisit(event, definition) { }; } +function materializeCanonicalThirdCampExplorationVisit( + event, + visitId, + campStep +) { + if (!event.body.includes('id: thirdCampExplorationVisitId')) { + return event; + } + const quote = (value) => + `'${String(value).replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`; + const canonicalSource = + `{ id: ${quote(visitId)}, ` + + 'availableAfterBattleIds: [campBattleIds.third], ' + + `availableDuringSteps: [${quote(campStep)}], `; + return { + ...event, + body: canonicalSource + event.body.slice(1) + }; +} + function validateCampEventCollection(events, collectionName, campBattleIdKeys, isCampaignStep) { const seenIds = new Map(); let campaignStepReferenceCount = 0; diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 0ae0a93..3227eed 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -1632,7 +1632,7 @@ try { if ( fourthBattleState.camera?.mapWidth !== 24 || fourthBattleState.camera?.mapHeight !== 20 || - fourthBattleState.victoryConditionLabel !== '장각 격파' || + fourthBattleState.victoryConditionLabel !== '장각 본대 지휘 와해' || fourthEnemies.length < 12 || !fourthEnemyBehaviors.has('aggressive') || !fourthEnemyBehaviors.has('guard') || diff --git a/scripts/verify-prologue-exploration-asset-data.mjs b/scripts/verify-prologue-exploration-asset-data.mjs index ec68c3a..934775c 100644 --- a/scripts/verify-prologue-exploration-asset-data.mjs +++ b/scripts/verify-prologue-exploration-asset-data.mjs @@ -5,7 +5,8 @@ const explorationAssetDirectory = join('src', 'assets', 'images', 'exploration') const expectedAssets = [ 'prologue-village.webp', 'prologue-militia-camp.webp', - 'second-pursuit-village-ferry.webp' + 'second-pursuit-village-ferry.webp', + 'third-guangzong-sortie-camp.webp' ]; const expectedWidth = 1920; const expectedHeight = 1080; diff --git a/scripts/verify-story-asset-data.mjs b/scripts/verify-story-asset-data.mjs index 01578e0..b6ca033 100644 --- a/scripts/verify-story-asset-data.mjs +++ b/scripts/verify-story-asset-data.mjs @@ -9,7 +9,7 @@ const validDirections = new Set(['south', 'east', 'north', 'west']); const portraitlessActorIds = new Set(['rebel-leader']); const maxAdjacentBackgroundDuplicateRate = 0.02; const maxAdjacentBackgroundRun = 3; -const expectedStoryPageCount = 468; +const expectedStoryPageCount = 469; const server = await createServer({ logLevel: 'error', diff --git a/scripts/verify-story-environment-profiles.mjs b/scripts/verify-story-environment-profiles.mjs index 1263a76..a243dd3 100644 --- a/scripts/verify-story-environment-profiles.mjs +++ b/scripts/verify-story-environment-profiles.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { createServer } from 'vite'; -const expectedStoryPageCount = 468; +const expectedStoryPageCount = 469; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true, hmr: false }, diff --git a/scripts/verify-third-battle-priority-return-browser.mjs b/scripts/verify-third-battle-priority-return-browser.mjs index ef1e1d8..dd8be15 100644 --- a/scripts/verify-third-battle-priority-return-browser.mjs +++ b/scripts/verify-third-battle-priority-return-browser.mjs @@ -148,6 +148,8 @@ async function verifyFixture(browser, baseUrl, fixture) { ); }); } + await page.waitForTimeout(1500); + await stopActiveGameScenes(page); const seeded = await seedThirdVictoryCamp(page, fixture); assert.deepEqual( @@ -179,14 +181,21 @@ async function verifyFixture(browser, baseUrl, fixture) { page, fixture, false, - true + false ); assert.equal(camp.campaign.step, 'third-camp'); assert.equal(camp.campBattleId, sourceBattleId); assert.equal( camp.activeTab, - 'dialogue', - `${fixture.id}: a pending priority return must reclaim focus after CampScene reuse.` + 'visit', + `${fixture.id}: the new walkable sortie camp must reclaim first focus after CampScene reuse.` + ); + assert.equal(camp.thirdCampExploration.available, true); + assert.equal(camp.thirdCampExploration.selected, true); + assert.equal(camp.thirdCampExploration.mode, 'explore'); + assertBoundsInsideViewport( + camp.thirdCampExploration.explorationButtonBounds, + `${fixture.id} third-camp exploration button` ); if (fixture.reducedMotion) { assert.equal( @@ -212,7 +221,7 @@ async function verifyFixture(browser, baseUrl, fixture) { } assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, { completed: false, - requireRowBounds: true + requireRowBounds: false }); const dialogueTab = camp.campTabs.find( @@ -244,6 +253,18 @@ async function verifyFixture(browser, baseUrl, fixture) { `dist/verification-third-battle-priority-return-${fixture.id}-tab-badge.png` ); + await clickSceneBounds( + page, + 'CampScene', + dialogueTab.bounds + ); + camp = await waitForPriorityReturn( + page, + fixture, + false, + true + ); + assert.equal(camp.activeTab, 'dialogue'); const priorityReturn = camp.thirdBattlePriorityReturn; assertPriorityReturn(priorityReturn, fixture, { completed: false, @@ -405,6 +426,8 @@ async function verifyFixture(browser, baseUrl, fixture) { }); await waitForDebugApi(page); await assertFhdViewport(page, fixture); + await page.waitForTimeout(1500); + await stopActiveGameScenes(page); await page.evaluate(async () => { await window.__HEROS_DEBUG__.goToCamp(); }); @@ -620,6 +643,15 @@ async function waitForDebugApi(page) { ); } +async function stopActiveGameScenes(page) { + await page.evaluate(() => { + const game = window.__HEROS_GAME__; + for (const scene of game?.scene.getScenes(true) ?? []) { + game.scene.stop(scene.scene.key); + } + }); +} + async function waitForPriorityReturn( page, fixture, diff --git a/scripts/verify-third-camp-preparation-browser.mjs b/scripts/verify-third-camp-preparation-browser.mjs index 9949390..7d13898 100644 --- a/scripts/verify-third-camp-preparation-browser.mjs +++ b/scripts/verify-third-camp-preparation-browser.mjs @@ -14,6 +14,9 @@ const selectionRecordId = 'third-camp-preparation-priority'; const trackedEnemyUnitId = 'guangzong-main-vanguard-a'; const priorityDialogueId = 'guan-zhang-after-guangzong-road'; const priorityDialogueBondId = 'guan-yu__zhang-fei'; +const explorationVisitId = 'third-guangzong-sortie-camp'; +const explorationActivityMarkerPrefix = + `${explorationVisitId}:activity:`; const activeThroughTurn = 3; const priorityFixtures = [ @@ -126,10 +129,11 @@ async function verifyRenderer(browser, baseUrl, rendererFixture) { await waitForDebugApi(page); await assertFhdViewport(page, rendererFixture); - if (rendererFixture.renderer === 'webgl') { - await verifyCampPreparationUi(page); - await assertFhdViewport(page, rendererFixture); - } + await verifyCampPreparationUi( + page, + rendererFixture.renderer + ); + await assertFhdViewport(page, rendererFixture); for (const priority of priorityFixtures) { await verifyBattlePriority( @@ -163,7 +167,11 @@ async function verifyRenderer(browser, baseUrl, rendererFixture) { } } -async function verifyCampPreparationUi(page) { +async function verifyCampPreparationUi(page, renderer) { + // Let the initial no-save title/story transition finish before replacing + // the campaign fixture, otherwise its already queued scene start can race + // the first exploration round trip. + await page.waitForTimeout(1500); const seeded = await seedThirdVictory(page, { acknowledgeActivities: false, selectPriorityId: null @@ -190,22 +198,19 @@ async function verifyCampPreparationUi(page) { }); let camp = await waitForPreparationToggle(page); assert.equal(camp.sortieVisible, true); - assert.equal( - preparationPriority(camp, 'information').selectable, - true, - 'Opening the briefing must count as reviewing the unlocked battle information.' - ); - assert.equal( - preparationPriority(camp, 'information').evidence?.category, - 'unlocks' - ); - assert.equal( - preparationPriority(camp, 'equipment').selectable, - false - ); - assert.equal( - preparationPriority(camp, 'companion').selectable, - false + for (const priority of priorityFixtures) { + assert.equal( + preparationPriority(camp, priority.id).selectable, + false, + `Opening the sortie briefing must not falsely complete ${priority.id}.` + ); + } + assert.equal(camp.thirdCampExploration.available, true); + assert.equal(camp.thirdCampExploration.entered, false); + assert.equal(camp.thirdCampExploration.mode, 'explore'); + assert.deepEqual( + camp.thirdCampExploration.completedActivityIds, + [] ); assertFiniteBounds( camp.thirdCampPreparation.toggleButtonBounds, @@ -220,18 +225,8 @@ async function verifyCampPreparationUi(page) { assertPreparationBrowserLayout(camp, 'initial browser'); await capture( page, - 'dist/verification-third-camp-preparation-webgl-browser.png' + `dist/verification-third-camp-exploration-${renderer}-locked.png` ); - - await selectPreparationCard(page, 'information'); - camp = await waitForCampSelection(page, 'information'); - assertPreparationSelection(camp, 'information'); - await assertCampaignSelectionPersisted( - page, - 'information', - 'information selection' - ); - const equipmentLocked = preparationPriority(camp, 'equipment'); assert.equal(equipmentLocked.selectable, false); @@ -240,136 +235,368 @@ async function verifyCampPreparationUi(page) { 'CampScene', equipmentLocked.actionButtonBounds ); - await page.waitForFunction( - () => { - const state = window.__HEROS_DEBUG__?.camp?.(); - return ( - state?.activeTab === 'equipment' && - state?.sortieVisible === false && - state?.thirdCampPreparation?.priorities?.find( - ({ id }) => id === 'equipment' - )?.selectable === true - ); - }, - undefined, - { timeout: 30000 } - ); - camp = await readCamp(page); - assert.equal(camp.activeTab, 'equipment'); + let exploration = await waitForThirdCampExploration(page); + assert.equal(exploration.locationId, explorationVisitId); + assert.equal(exploration.viewport.width, 1920); + assert.equal(exploration.viewport.height, 1080); assert.equal( - preparationPriority(camp, 'equipment').evidence?.category, - 'equipment' + exploration.background.key, + 'third-guangzong-sortie-camp-background' ); - assertPreparationSelection(camp, 'information'); - - camp = await openPreparationBrowser(page); - assertPreparationBrowserLayout(camp, 'equipment unlocked'); - await selectPreparationCard(page, 'equipment'); - camp = await waitForCampSelection(page, 'equipment'); - assertPreparationSelection(camp, 'equipment'); - await assertCampaignSelectionPersisted( - page, - 'equipment', - 'equipment selection' + assert.equal(exploration.background.ready, true); + assert.equal(exploration.background.fallback, false); + assert.equal(exploration.background.sourceWidth, 1920); + assert.equal(exploration.background.sourceHeight, 1080); + assert.equal(exploration.requiredTexturesReady, true); + assert.deepEqual( + exploration.visit.completedActivityIds, + [] ); - - const companionLocked = - preparationPriority(camp, 'companion'); - assert.equal(companionLocked.selectable, false); - await clickSceneBounds( - page, - 'CampScene', - companionLocked.actionButtonBounds - ); - await page.waitForFunction( - (dialogueId) => { - const state = window.__HEROS_DEBUG__?.camp?.(); - return ( - state?.activeTab === 'dialogue' && - state?.sortieVisible === false && - state?.selectedDialogue?.id === dialogueId && - state.selectedDialogue.completed === false && - state.selectedDialogue.choices?.length > 0 - ); - }, - priorityDialogueId, - { timeout: 30000 } - ); - camp = await readCamp(page); assert.equal( - camp.selectedDialogue.thirdBattlePriorityReturn, - true, - 'The companion activity must route to the exact result-priority return dialogue.' + exploration.visit.hudActivityTitle, + '자유 준비 0 / 3' ); - const dialogueChoice = camp.selectedDialogue.choices[0]; - assertFiniteBounds( - dialogueChoice.bounds, - `dialogue choice ${dialogueChoice.id}` + assert.equal( + exploration.visit.hudPriorityLocation, + '아직 정하지 않음' ); - assertBoundsInsideViewport( - dialogueChoice.bounds, - `dialogue choice ${dialogueChoice.id}` + assert.equal(exploration.visit.selectedPriorityId, null); + assert.equal(exploration.storyContext.sourceBattleId, sourceBattleId); + assert.equal(exploration.storyContext.targetBattleId, targetBattleId); + assert.equal( + exploration.storyContext.companionDialogueId, + priorityDialogueId ); - await clickSceneBounds( - page, - 'CampScene', - dialogueChoice.bounds + assert.equal( + exploration.storyContext.companionBondId, + priorityDialogueBondId ); - await page.waitForFunction( - ({ dialogueId, choiceId }) => { - const state = window.__HEROS_DEBUG__?.camp?.(); - return ( - state?.selectedDialogue?.id === dialogueId && - state.selectedDialogue.completed === true && - state.campaign?.campDialogueChoiceIds?.[dialogueId] === - choiceId && - state?.thirdCampPreparation?.priorities?.find( - ({ id }) => id === 'companion' - )?.selectable === true - ); - }, - { - dialogueId: priorityDialogueId, - choiceId: dialogueChoice.id - }, - { timeout: 30000 } + assert.deepEqual( + exploration.actors.map(({ id }) => id).sort(), + [ + 'companion-guan-yu', + 'companion-zhang-fei', + 'quartermaster-equipment', + 'zou-jing-operations' + ] ); + assert( + exploration.actors.every(({ moved }) => moved === false), + 'Third-camp NPCs must begin at stable authored positions.' + ); + assert.equal(exploration.dialogue.active, true); + assert.equal(exploration.dialogue.portraitVisible, true); + await advanceExplorationDialogue(page); + exploration = await readThirdCampExploration(page); + assert.equal(exploration.dialogue.active, false); - camp = await openPreparationBrowser(page); - assertPreparationBrowserLayout(camp, 'companion unlocked'); - const companion = preparationPriority(camp, 'companion'); - assert.equal(companion.evidenceKind, 'priority-return-dialogue'); - assert.equal(companion.evidence.dialogueId, priorityDialogueId); - assert.equal(companion.evidence.bondId, priorityDialogueBondId); - await selectPreparationCard(page, 'companion'); - camp = await waitForCampSelection(page, 'companion'); - assertPreparationSelection(camp, 'companion'); - await assertCampaignSelectionPersisted( - page, - 'companion', - 'companion selection' - ); await capture( page, - 'dist/verification-third-camp-preparation-webgl-selected.png' + `dist/verification-third-camp-exploration-${renderer}-arrival.png` ); + const exitStarted = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene( + 'CampVisitExplorationScene' + ); + return scene?.debugInteractWith?.('camp-exit') ?? false; + }); + assert.equal( + exitStarted, + true, + 'The camp exit must remain usable before any optional activity.' + ); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.campVisitExploration?.() + ?.dialogue?.active === true + ); + await advanceExplorationDialogue(page); + try { + await waitForCamp(page, 15000); + } catch (error) { + const state = await page.evaluate(() => ({ + exploration: + window.__HEROS_DEBUG__?.campVisitExploration?.(), + camp: window.__HEROS_DEBUG__?.camp?.(), + activeScenes: + window.__HEROS_GAME__?.scene + .getScenes(true) + .map((scene) => scene.scene.key) ?? [] + })); + throw new Error( + `Optional third-camp exit did not return to Camp: ${JSON.stringify( + state + )}`, + { cause: error } + ); + } + await page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + scene?.showSortiePrep?.(); + }); + camp = await waitForPreparationToggle(page); + const optionalChecklist = camp.sortieChecklist.find( + ({ label }) => label === '출진 우선 준비' + ); + assert.equal(optionalChecklist?.complete, true); + assert( + optionalChecklist?.detail.includes('보너스 없이 출진 가능'), + 'The sortie checklist must explicitly allow sortie without a preparation bonus.' + ); + assert.equal(camp.thirdCampPreparation.selectedPriorityId, null); + + exploration = await openThirdCampExplorationFromCamp(page); + assert.equal( + exploration.dialogue.active, + false, + 'The introduction must not repeat after the first recorded entry.' + ); + + exploration = await completeThirdCampNpcActivity( + page, + 'quartermaster-equipment', + 'equipment' + ); + assert.equal(exploration.visit.completedActivityCount, 1); + assert.equal(exploration.visit.selectedPriorityId, 'equipment'); + assert.equal( + exploration.visit.hudActivityTitle, + '자유 준비 1 / 3' + ); + assert.equal( + exploration.visit.hudPriorityLocation, + '장비 정비' + ); + assertThirdCampWorldFeedback(exploration, ['equipment']); + assert( + exploration.campaign.completedCampVisits.includes( + `${explorationActivityMarkerPrefix}equipment` + ) + ); + + const partialExit = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene( + 'CampVisitExplorationScene' + ); + return scene?.debugInteractWith?.('camp-exit') ?? false; + }); + assert.equal(partialExit, true); + await waitForCamp(page); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); + await page.waitForTimeout(1500); await page.evaluate(async () => { + const game = window.__HEROS_GAME__; + for (const scene of game?.scene.getScenes(true) ?? []) { + game.scene.stop(scene.scene.key); + } await window.__HEROS_DEBUG__.goToCamp(); }); await waitForCamp(page); - camp = await openPreparationBrowser(page); - assertPreparationBrowserLayout(camp, 're-entered browser'); - assertPreparationSelection(camp, 'companion'); - await assertCampaignSelectionPersisted( + exploration = await openThirdCampExplorationFromCamp(page); + assert.deepEqual( + exploration.visit.completedActivityIds, + ['equipment'], + 'A partial activity must survive a Camp round trip and page reload.' + ); + assert.equal(exploration.visit.selectedPriorityId, 'equipment'); + assertThirdCampWorldFeedback(exploration, ['equipment']); + + exploration = await completeThirdCampNpcActivity( page, - 'companion', - 're-entered companion selection' + 'zou-jing-operations', + 'information' + ); + assert.deepEqual( + [...exploration.visit.completedActivityIds].sort(), + ['equipment', 'information'] + ); + assert.equal(exploration.visit.selectedPriorityId, 'information'); + assertThirdCampWorldFeedback( + exploration, + ['equipment', 'information'] + ); + + const companionActor = exploration.actors.find( + ({ activityId }) => activityId === 'companion' + ); + assert(companionActor, 'Expected a result-specific companion actor.'); + const bondExpBefore = await readCampaignBondExp( + page, + exploration.storyContext.companionBondId + ); + const companionStarted = await page.evaluate((actorId) => { + const scene = window.__HEROS_GAME__?.scene.getScene( + 'CampVisitExplorationScene' + ); + return scene?.debugInteractWith?.(actorId) ?? false; + }, companionActor.id); + assert.equal(companionStarted, true); + await advanceExplorationDialogue(page); + exploration = await readThirdCampExploration(page); + assert.equal(exploration.choice.open, true); + assert.equal(exploration.choice.mode, 'third-companion'); + assert.equal(exploration.choice.sourceNpcId, companionActor.id); + assert(exploration.choice.choices.length > 0); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.campVisitExploration?.() + ?.choice?.ready === true + ); + exploration = await readThirdCampExploration(page); + const companionChoice = exploration.choice.choices[0]; + assertFiniteBounds( + companionChoice.bounds, + 'third-camp companion choice' + ); + assertBoundsInsideViewport( + companionChoice.bounds, + 'third-camp companion choice' + ); + await clickSceneBounds( + page, + 'CampVisitExplorationScene', + companionChoice.bounds + ); + await page.waitForFunction( + () => { + const state = + window.__HEROS_DEBUG__?.campVisitExploration?.(); + return ( + state?.visit?.completedActivityCount === 3 && + state?.visit?.selectedPriorityId === 'companion' + ); + } + ); + await advanceExplorationDialogue(page); + exploration = await readThirdCampExploration(page); + assert.deepEqual( + [...exploration.visit.completedActivityIds].sort(), + ['companion', 'equipment', 'information'] + ); + assert.equal( + exploration.visit.hudActivityTitle, + '자유 준비 3 / 3' + ); + assert.equal( + exploration.visit.hudPriorityLocation, + '동료 공명' + ); + assertThirdCampWorldFeedback( + exploration, + ['companion', 'equipment', 'information'] + ); + assert( + exploration.actors.every(({ moved }) => moved === false), + 'Talking to a companion must not teleport any camp actor.' + ); + const bondExpAfter = await readCampaignBondExp( + page, + exploration.storyContext.companionBondId + ); + assert( + bondExpAfter > bondExpBefore, + 'The first companion choice must award its bond experience.' + ); + + await page.waitForFunction( + () => { + const state = + window.__HEROS_DEBUG__?.campVisitExploration?.(); + return ( + state?.dialogue?.active === false && + state?.choice?.open === false && + state?.navigationPending === false + ); + } + ); + const companionRepeatStarted = await page.evaluate((actorId) => { + const scene = window.__HEROS_GAME__?.scene.getScene( + 'CampVisitExplorationScene' + ); + return scene?.debugInteractWith?.(actorId) ?? false; + }, companionActor.id); + assert.equal(companionRepeatStarted, true); + await advanceExplorationDialogue(page); + const bondExpAfterRepeat = await readCampaignBondExp( + page, + exploration.storyContext.companionBondId + ); + assert.equal( + bondExpAfterRepeat, + bondExpAfter, + 'Repeating the companion activity must not duplicate bond rewards.' + ); + + await capture( + page, + `dist/verification-third-camp-exploration-${renderer}-complete.png` + ); + const completedExit = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene( + 'CampVisitExplorationScene' + ); + return scene?.debugInteractWith?.('camp-exit') ?? false; + }); + assert.equal(completedExit, true); + await waitForCamp(page); + await page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + scene?.showSortiePrep?.(); + }); + camp = await waitForPreparationToggle(page); + camp = await openPreparationBrowser(page); + assertPreparationBrowserLayout(camp, 'all exploration activities'); + for (const priority of priorityFixtures) { + const availability = preparationPriority(camp, priority.id); + assert.equal(availability.selectable, true); + assert.equal( + availability.evidenceKind, + 'exploration-activity', + `${priority.id} must be unlocked by its explicit walkable-camp activity marker.` + ); + assert.equal( + availability.evidence.visitId, + explorationVisitId + ); + assert.equal( + availability.evidence.activityId, + priority.id + ); + } + assertPreparationSelection(camp, 'companion'); + assertFiniteBounds( + camp.thirdCampPreparation.clearButtonBounds, + 'preparation clear button' + ); + await clickSceneBounds( + page, + 'CampScene', + camp.thirdCampPreparation.clearButtonBounds + ); + await page.waitForFunction( + () => { + const state = window.__HEROS_DEBUG__?.camp?.(); + return ( + state?.thirdCampPreparation?.browserOpen === true && + state.thirdCampPreparation.selectedPriorityId === null && + state.thirdCampPreparation.ready === false + ); + } + ); + camp = await readCamp(page); + const clearedChecklist = camp.sortieChecklist.find( + ({ label }) => label === '출진 우선 준비' + ); + assert.equal(clearedChecklist?.complete, true); + assert( + clearedChecklist?.detail.includes('보너스 없이 출진 가능') ); assert.equal( camp.campaign.completedCampVisits.includes( @@ -378,6 +605,192 @@ async function verifyCampPreparationUi(page) { false, 'A mutable preparation selection must not masquerade as a completed camp visit.' ); + assert.equal( + camp.campaign.completedCampVisits.filter((visitId) => + visitId.startsWith(explorationActivityMarkerPrefix) + ).length, + 3 + ); + await assertCampaignSelectionPersisted( + page, + null, + 'cleared optional preparation' + ); +} + +async function waitForThirdCampExploration(page) { + try { + await page.waitForFunction( + (visitId) => { + const state = + window.__HEROS_DEBUG__?.campVisitExploration?.(); + return ( + state?.scene === 'CampVisitExplorationScene' && + state?.ready === true && + state?.locationId === visitId + ); + }, + explorationVisitId, + { timeout: 30000 } + ); + } catch (error) { + const state = await page.evaluate(() => ({ + exploration: + window.__HEROS_DEBUG__?.campVisitExploration?.(), + camp: window.__HEROS_DEBUG__?.camp?.(), + activeScenes: + window.__HEROS_GAME__?.scene + .getScenes(true) + .map((scene) => scene.scene.key) ?? [] + })); + throw new Error( + `Third-camp exploration did not become ready: ${JSON.stringify( + state + )}`, + { cause: error } + ); + } + return readThirdCampExploration(page); +} + +async function openThirdCampExplorationFromCamp(page) { + let camp = await readCamp(page); + if (camp?.sortieVisible) { + await page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + scene?.hideSortiePrep?.(); + }); + } + camp = await readCamp(page); + if (camp?.activeTab !== 'visit') { + await page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + if (!scene) { + return; + } + scene.activeTab = 'visit'; + scene.render(); + }); + } + await page.waitForFunction( + () => { + const state = window.__HEROS_DEBUG__?.camp?.(); + return ( + state?.activeTab === 'visit' && + state?.thirdCampExploration?.available === true && + state.thirdCampExploration.explorationInteractive === true && + Boolean( + state.thirdCampExploration.explorationButtonBounds + ) + ); + } + ); + camp = await readCamp(page); + await clickSceneBounds( + page, + 'CampScene', + camp.thirdCampExploration.explorationButtonBounds + ); + const exploration = await waitForThirdCampExploration(page); + const activeScenes = await page.evaluate(() => + window.__HEROS_GAME__?.scene + .getScenes(true) + .map((scene) => scene.scene.key) ?? [] + ); + assert.equal( + activeScenes.includes('CampScene'), + false, + 'The walkable visit must replace CampScene instead of rendering over it.' + ); + return exploration; +} + +async function readThirdCampExploration(page) { + return page.evaluate(() => + window.__HEROS_DEBUG__.campVisitExploration() + ); +} + +async function advanceExplorationDialogue(page) { + for (let step = 0; step < 20; step += 1) { + const state = await page.evaluate(() => + window.__HEROS_DEBUG__?.campVisitExploration?.() + ); + if ( + state?.active === false || + state?.choice?.open || + state?.dialogue?.active === false + ) { + return state; + } + const advanced = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene( + 'CampVisitExplorationScene' + ); + return scene?.debugAdvanceDialogue?.() ?? false; + }); + assert.equal( + advanced, + true, + 'Expected the active exploration dialogue to advance.' + ); + } + assert.fail('Exploration dialogue did not finish within 20 advances.'); +} + +async function completeThirdCampNpcActivity( + page, + actorId, + activityId +) { + const started = await page.evaluate((targetId) => { + const scene = window.__HEROS_GAME__?.scene.getScene( + 'CampVisitExplorationScene' + ); + return scene?.debugInteractWith?.(targetId) ?? false; + }, actorId); + assert.equal(started, true, `Expected ${actorId} interaction to start.`); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.campVisitExploration?.() + ?.dialogue?.active === true + ); + await advanceExplorationDialogue(page); + await page.waitForFunction( + (expectedActivityId) => + window.__HEROS_DEBUG__?.campVisitExploration?.() + ?.visit?.completedActivityIds?.includes( + expectedActivityId + ), + activityId + ); + return readThirdCampExploration(page); +} + +function assertThirdCampWorldFeedback(state, completedIds) { + const completed = new Set(completedIds); + for (const feedback of state.worldFeedback) { + assert.equal( + feedback.visible, + completed.has(feedback.activityId), + `${feedback.activityId} world feedback visibility` + ); + } +} + +async function readCampaignBondExp(page, bondId) { + return page.evaluate(async (targetBondId) => { + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + return ( + campaignModule + .getCampaignState() + .bonds.find(({ id }) => id === targetBondId)?.exp ?? 0 + ); + }, bondId); } async function verifyBattlePriority( @@ -660,6 +1073,12 @@ async function seedThirdVictory( const actionModule = await import( '/heros_web/src/game/state/thirdCampPreparationActions.ts' ); + const explorationActionModule = await import( + '/heros_web/src/game/state/thirdCampExplorationActions.ts' + ); + const explorationDataModule = await import( + '/heros_web/src/game/data/thirdCampExploration.ts' + ); const preparationModule = await import( '/heros_web/src/game/data/thirdCampPreparation.ts' ); @@ -749,23 +1168,42 @@ async function seedThirdVictory( campaignModule.completeCampaignAftermath(sourceBattleId); if (acknowledgeActivities) { - campaignModule.acknowledgeCampaignVictoryReward( - sourceBattleId, - ['unlocks', 'equipment'] - ); - const dialogueReport = campaignModule.applyCampBondExp( - priorityDialogueId, - priorityDialogueBondId, - 1, - 'verify-third-camp-preparation' - ); - if ( - !dialogueReport?.completedCampDialogues.includes( - priorityDialogueId - ) - ) { + const entry = + explorationActionModule.enterThirdCampExploration(); + if (!entry.ok) { throw new Error( - 'Failed to complete the priority return dialogue seed.' + `Failed to enter third-camp exploration: ${entry.reason}` + ); + } + for (const activityId of ['information', 'equipment']) { + const activity = + explorationActionModule.recordThirdCampExplorationActivity( + activityId + ); + if (!activity.ok) { + throw new Error( + `Failed to complete ${activityId}: ${activity.reason}` + ); + } + } + const definition = + explorationDataModule.resolveThirdCampExplorationDefinition( + campaignModule.getCampaignState() + ); + const companionChoiceId = + definition?.companionDialogue.choices[0]?.id; + if (!companionChoiceId) { + throw new Error( + 'Failed to resolve the priority return companion choice.' + ); + } + const companion = + explorationActionModule.completeThirdCampCompanionActivity( + companionChoiceId + ); + if (!companion.ok) { + throw new Error( + `Failed to complete companion activity: ${companion.reason}` ); } } @@ -1128,7 +1566,7 @@ async function waitForDebugApi(page) { ); } -async function waitForCamp(page) { +async function waitForCamp(page, timeout = 90000) { await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.camp?.(); @@ -1143,7 +1581,7 @@ async function waitForCamp(page) { ); }, undefined, - { timeout: 90000 } + { timeout } ); return readCamp(page); } @@ -1169,42 +1607,54 @@ async function waitForPreparationToggle(page) { } async function openPreparationBrowser(page) { - const before = await readCamp(page); - if (before?.thirdCampPreparation?.browserOpen) { - return before; - } - if (!before?.sortieVisible) { - await page.evaluate(() => { - const scene = - window.__HEROS_GAME__?.scene.getScene('CampScene'); - scene.showSortiePrep(); - }); - } - const ready = await waitForPreparationToggle(page); - await clickSceneBounds( - page, - 'CampScene', - ready.thirdCampPreparation.toggleButtonBounds - ); - await page.waitForFunction( - () => { - const preparation = - window.__HEROS_DEBUG__?.camp?.() - ?.thirdCampPreparation; - return ( - preparation?.browserOpen === true && - Boolean(preparation.closeButtonBounds) && - preparation.priorities?.length === 3 && - preparation.priorities.every( - ({ cardBounds, actionButtonBounds }) => - Boolean(cardBounds && actionButtonBounds) - ) + let lastState = await readCamp(page); + for (let attempt = 0; attempt < 2; attempt += 1) { + if (!lastState?.thirdCampPreparation?.browserOpen) { + if (!lastState?.sortieVisible) { + await page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + scene.showSortiePrep(); + }); + } + const ready = await waitForPreparationToggle(page); + await clickSceneBounds( + page, + 'CampScene', + ready.thirdCampPreparation.toggleButtonBounds ); - }, - undefined, - { timeout: 30000 } + } + await page.waitForFunction( + () => { + const preparation = + window.__HEROS_DEBUG__?.camp?.() + ?.thirdCampPreparation; + return ( + preparation?.browserOpen === true && + Boolean(preparation.closeButtonBounds) && + preparation.priorities?.length === 3 && + preparation.priorities.every( + ({ cardBounds, actionButtonBounds }) => + Boolean(cardBounds && actionButtonBounds) + ) + ); + }, + undefined, + { timeout: 30000 } + ); + await page.waitForTimeout(150); + lastState = await readCamp(page); + if (lastState?.thirdCampPreparation?.browserOpen) { + return lastState; + } + } + throw new Error( + `Preparation browser did not remain open: ${JSON.stringify({ + sortieVisible: lastState?.sortieVisible, + activeTab: lastState?.activeTab, + preparation: lastState?.thirdCampPreparation + })}` ); - return readCamp(page); } async function selectPreparationCard(page, priorityId) { @@ -1384,14 +1834,21 @@ async function assertCampaignSelectionPersisted( return raw ? JSON.parse(raw) : null; }); assert(persisted, `${label}: campaign save is missing.`); - assert.deepEqual( - persisted.thirdCampPreparationSelection, - { - sourceBattleId, - targetBattleId, - priorityId - } - ); + if (priorityId) { + assert.deepEqual( + persisted.thirdCampPreparationSelection, + { + sourceBattleId, + targetBattleId, + priorityId + } + ); + } else { + assert.equal( + persisted.thirdCampPreparationSelection, + undefined + ); + } assert.equal( persisted.completedCampVisits.includes( selectionRecordId diff --git a/scripts/verify-third-camp-preparation.mjs b/scripts/verify-third-camp-preparation.mjs index 58da91e..4ab9180 100644 --- a/scripts/verify-third-camp-preparation.mjs +++ b/scripts/verify-third-camp-preparation.mjs @@ -36,6 +36,12 @@ try { const actionModule = await server.ssrLoadModule( '/src/game/state/thirdCampPreparationActions.ts' ); + const explorationDataModule = await server.ssrLoadModule( + '/src/game/data/thirdCampExploration.ts' + ); + const explorationActionModule = await server.ssrLoadModule( + '/src/game/state/thirdCampExplorationActions.ts' + ); const campaignModule = await server.ssrLoadModule( '/src/game/state/campaignState.ts' ); @@ -50,6 +56,8 @@ try { verifyGuardedPersistentOverwrite( dataModule, actionModule, + explorationDataModule, + explorationActionModule, campaignModule, battleScenarios ); @@ -172,17 +180,29 @@ function verifyActivityGates(dataModule) { }); availability = dataModule.resolveThirdCampPreparationAvailability(acknowledged); + assert( + availability.every(({ selectable }) => selectable === false), + 'Opening reward categories alone must not complete walkable-camp activities.' + ); + + const explored = literalCampaign(dataModule, { + completedActivities: ['information', 'equipment'] + }); + availability = + dataModule.resolveThirdCampPreparationAvailability(explored); assert.equal(availability[0].selectable, true); assert.deepEqual(availability[0].evidence, { - kind: 'victory-reward-category', + kind: 'exploration-activity', battleId: dataModule.thirdCampPreparationSourceBattleId, - category: 'unlocks' + visitId: 'third-guangzong-sortie-camp', + activityId: 'information' }); assert.equal(availability[1].selectable, true); assert.deepEqual(availability[1].evidence, { - kind: 'victory-reward-category', + kind: 'exploration-activity', battleId: dataModule.thirdCampPreparationSourceBattleId, - category: 'equipment' + visitId: 'third-guangzong-sortie-camp', + activityId: 'equipment' }); assert.equal(availability[2].selectable, false); @@ -198,6 +218,7 @@ function verifyActivityGates(dataModule) { assert.equal(availability[1].selectable, false); const companion = literalCampaign(dataModule, { + completedActivities: ['companion'], completePriorityDialogue: true }); availability = @@ -208,7 +229,7 @@ function verifyActivityGates(dataModule) { assert.equal(companionAvailability?.selectable, true); assert.equal( companionAvailability?.evidence?.kind, - 'priority-return-dialogue' + 'exploration-activity' ); assert.equal( companionAvailability?.evidence?.dialogueId, @@ -219,6 +240,18 @@ function verifyActivityGates(dataModule) { 'guan-yu__zhang-fei' ); + const legacyCompanion = literalCampaign(dataModule, { + completePriorityDialogue: true + }); + assert.equal( + dataModule.resolveThirdCampPreparationAvailability( + legacyCompanion + ).find(({ priority }) => priority.id === 'companion') + ?.evidence?.kind, + 'priority-return-dialogue', + 'An already completed result dialogue must remain valid for old saves.' + ); + const unrelatedDialogue = literalCampaign(dataModule); unrelatedDialogue.completedCampDialogues = [ 'liu-guan-after-guangzong-road' @@ -299,7 +332,7 @@ function verifyTargetBattleMemoryIsolation(dataModule) { ); const stale = eligibleLiteralCampaign(dataModule, 'information'); - stale.acknowledgedVictoryRewardCategories = {}; + stale.completedCampVisits = []; assert.equal( dataModule.resolveThirdCampPreparationMemory({ campaign: stale, @@ -409,7 +442,7 @@ function verifyBattleLifecycleContract(dataModule) { memory.evidence.unitIds[0] = 'mutated'; } assert.notEqual(fresh?.label, 'mutated'); - if (fresh?.evidence.kind === 'priority-return-dialogue') { + if (fresh && 'unitIds' in fresh.evidence) { assert.notEqual(fresh.evidence.unitIds[0], 'mutated'); } }); @@ -418,6 +451,8 @@ function verifyBattleLifecycleContract(dataModule) { function verifyGuardedPersistentOverwrite( dataModule, actionModule, + explorationDataModule, + explorationActionModule, campaignModule, battleScenarios ) { @@ -432,9 +467,16 @@ function verifyGuardedPersistentOverwrite( 'activity-required' ); - campaignModule.acknowledgeCampaignVictoryReward( - dataModule.thirdCampPreparationSourceBattleId, - ['unlocks'] + const entry = explorationActionModule.enterThirdCampExploration(); + assert.equal(entry.ok, true); + const informationActivity = + explorationActionModule.recordThirdCampExplorationActivity( + 'information' + ); + assert.equal(informationActivity.ok, true); + assert.equal( + actionModule.clearThirdCampPreparationPriority().ok, + true ); let result = actionModule.setThirdCampPreparationPriority('information'); @@ -465,19 +507,35 @@ function verifyGuardedPersistentOverwrite( 'A rejected replacement must preserve the previous valid priority.' ); - campaignModule.acknowledgeCampaignVictoryReward( - dataModule.thirdCampPreparationSourceBattleId, - ['equipment'] + const equipmentActivity = + explorationActionModule.recordThirdCampExplorationActivity( + 'equipment' + ); + assert.equal(equipmentActivity.ok, true); + assert.equal( + actionModule.clearThirdCampPreparationPriority().ok, + true ); result = actionModule.setThirdCampPreparationPriority('equipment'); assert.equal(result.ok, true); assert.equal(result.changed, true); assert.equal(result.memory.priorityId, 'equipment'); - completePriorityDialogue( - dataModule, - campaignModule, - battleScenarios + const explorationDefinition = + explorationDataModule.resolveThirdCampExplorationDefinition( + campaignModule.getCampaignState() + ); + const companionChoiceId = + explorationDefinition?.companionDialogue.choices[0]?.id; + assert(companionChoiceId); + const companionActivity = + explorationActionModule.completeThirdCampCompanionActivity( + companionChoiceId + ); + assert.equal(companionActivity.ok, true); + assert.equal( + actionModule.clearThirdCampPreparationPriority().ok, + true ); result = actionModule.setThirdCampPreparationPriority('companion'); assert.equal(result.ok, true); @@ -883,12 +941,7 @@ function priorityDialogueForCampaign(campaign, dataModule) { function eligibleLiteralCampaign(dataModule, priorityId) { return literalCampaign(dataModule, { - acknowledgedCategories: - priorityId === 'information' - ? ['unlocks'] - : priorityId === 'equipment' - ? ['equipment'] - : [], + completedActivities: [priorityId], completePriorityDialogue: priorityId === 'companion', selectedPriorityId: priorityId }); @@ -898,6 +951,7 @@ function literalCampaign( dataModule, { acknowledgedCategories = [], + completedActivities = [], completePriorityDialogue = false, selectedPriorityId, legacyReportOnly = false @@ -926,7 +980,10 @@ function literalCampaign( completedCampDialogues: completePriorityDialogue ? ['guan-zhang-after-guangzong-road'] : [], - completedCampVisits: [], + completedCampVisits: completedActivities.map( + (activityId) => + `third-guangzong-sortie-camp:activity:${activityId}` + ), campVisitChoiceIds: {}, ...(selectedPriorityId ? { diff --git a/src/assets/images/exploration/third-guangzong-sortie-camp.webp b/src/assets/images/exploration/third-guangzong-sortie-camp.webp new file mode 100644 index 0000000..7b40268 Binary files /dev/null and b/src/assets/images/exploration/third-guangzong-sortie-camp.webp differ diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 4c8462c..5fc7b38 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -757,24 +757,24 @@ export const thirdBattleScenario: BattleScenarioDefinition = { export const fourthBattleScenario: BattleScenarioDefinition = { id: 'fourth-battle-guangzong-camp', title: '광종 본영전', - victoryConditionLabel: '장각 격파', + victoryConditionLabel: '장각 본대 지휘 와해', defeatConditionLabel: '유비 퇴각', openingObjectiveLines: [ - '황건 본영의 중심에는 장각이 있습니다. 장각을 격파하면 광종의 황건 세력은 무너집니다.', + '노식 중랑장의 포위망이 광종 본영을 묶었습니다. 의용군은 동쪽 숲 선봉을 맡아 장각 본대의 지휘를 무너뜨려야 합니다.', '요새와 숲의 궁병이 진입로를 막고 있습니다. 측면의 기병과 중앙 수비병을 나누어 상대하십시오.', '20턴 이내에 승리하면 황건적 토벌의 공적이 크게 오릅니다.' ], tacticalGuide: { - summary: '광종 본영 깊숙한 곳에서 장각을 직접 격파해 황건 토벌을 마무리하는 전투입니다.', - route: '출진 지점 -> 본영 입구 방어선 -> 측면 숲길 정리 -> 장각 본진', - focus: '관우는 요새 앞 전열을 고정하고, 장비는 숲길 궁병과 기병을 흔들며, 유비는 뒤에서 무리한 돌입을 막아야 합니다.', - roles: ['관우: 본영 입구 전열', '장비: 측면 숲길 압박', '유비: 후방 지휘/마무리 판단'], + summary: '관군의 광종 포위망 가운데 동쪽 숲 선봉을 맡아 장각 본대의 지휘를 무너뜨리는 전투입니다.', + route: '동쪽 선봉 출진점 -> 본영 입구 방어선 -> 측면 숲길 정리 -> 장각 지휘진', + focus: '관우는 동쪽 요새 앞 전열을 고정하고, 장비는 숲길 궁병과 기병을 흔들며, 유비는 포위망에서 홀로 튀어나가지 않도록 진격 속도를 맞춰야 합니다.', + roles: ['관우: 동쪽 본영 입구 전열', '장비: 측면 숲길 압박', '유비: 포위망 연계/후방 지휘'], events: { 'enemy-posture': { title: '광종 본영 진형', lines: [ - '장각은 요새와 숲길을 겹쳐 본영 입구를 막고 있습니다.', - '중앙을 서두르기 전에 측면 궁병을 줄이면 장각에게 붙는 길이 훨씬 안정됩니다.' + '장각 본대는 요새와 숲길을 겹쳐 노식의 포위망 가운데 동쪽 입구를 막고 있습니다.', + '중앙을 서두르기 전에 측면 궁병을 줄이면 지휘진에 닿는 길이 훨씬 안정됩니다.' ] }, 'cavalry-approach': { @@ -784,7 +784,7 @@ export const fourthBattleScenario: BattleScenarioDefinition = { 'leader-wavering': { title: '장각 본진 동요', lines: [ - '장각 주변 수비가 무너지면 황건 본영 전체가 흔들립니다.', + '장각의 지휘병과 주변 수비가 무너지면 황건 본대 전체가 흔들립니다.', '유비를 안전하게 두고 관우와 장비를 붙여 끝을 보십시오.' ] } @@ -794,7 +794,7 @@ export const fourthBattleScenario: BattleScenarioDefinition = { sortieLimit: 3, requiredUnits: ['liu-bei'], recommendedUnits: [ - { unitId: 'liu-bei', role: 'support', reason: '장각 격파까지 전선이 무너지지 않도록 후방 지휘가 필요합니다.' }, + { unitId: 'liu-bei', role: 'support', reason: '장각 본대의 지휘가 무너질 때까지 동쪽 선봉과 관군 포위망을 잇는 후방 지휘가 필요합니다.' }, { unitId: 'guan-yu', role: 'front', reason: '본영 정면의 요새 방어선을 뚫는 핵심 전열입니다.' }, { unitId: 'zhang-fei', role: 'flank', reason: '측면 궁병과 기병을 흔들어 본영 진입 시간을 줄입니다.' } ], @@ -821,7 +821,7 @@ export const fourthBattleScenario: BattleScenarioDefinition = { { id: 'leader', kind: 'defeat-leader', - label: '장각 격파', + label: '장각 본대 지휘 와해', rewardGold: 420, unitId: 'guangzong-main-leader-zhang-jue' }, diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 02e79b5..fe30fe1 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -633,7 +633,7 @@ export const fourthBattleIntroPages: StoryPage[] = [ bgm: 'militia-theme', chapter: '광종 본영', background: 'story-yellow-pursuit', - text: '전령의 길이 끊기자 황건 본대는 광종 들판에 진을 굳혔다. 노란 깃발이 겹겹이 서고, 장각의 이름이 병사들 사이에 낮게 울렸다.' + text: '전령의 길이 끊기자 노식 중랑장의 관군은 광종 포위망을 좁혔다. 황건 본대는 들판에 진을 굳혔고, 의용군에는 동쪽 숲 선봉을 맡으라는 군령이 내려왔다.' }, { id: 'fourth-liu-bei-resolve', @@ -642,7 +642,7 @@ export const fourthBattleIntroPages: StoryPage[] = [ background: 'story-yellow-pursuit', speaker: '유비', portrait: 'liuBei', - text: '장각을 치는 일은 한 사람을 꺾는 일이 아니다. 두려움에 몰린 백성들이 다시 길을 찾게 하는 일이다.' + text: '스승님의 큰 포위망을 흐트러뜨리지 않고 우리에게 맡겨진 동쪽 길을 열겠다. 한 사람의 목보다, 황건 본대가 더는 백성을 몰아세우지 못하게 지휘를 끊는 것이 먼저다.' }, { id: 'fourth-guan-yu-line', @@ -651,7 +651,7 @@ export const fourthBattleIntroPages: StoryPage[] = [ background: 'story-yellow-pursuit', speaker: '관우', portrait: 'guanYu', - text: '적은 요새와 숲 사이에 궁병을 숨겼습니다. 전열을 흐트러뜨리지 않고 본영의 깃발까지 밀고 들어가야 합니다.' + text: '적은 요새와 숲 사이에 궁병을 숨겼습니다. 동쪽 전열을 흐트러뜨리지 않고 지휘 깃발까지 밀고 들어가면 포위망 전체가 움직일 수 있습니다.' }, { id: 'fourth-zhang-fei-roar', @@ -660,7 +660,7 @@ export const fourthBattleIntroPages: StoryPage[] = [ background: 'story-yellow-pursuit', speaker: '장비', portrait: 'zhangFei', - text: '좋소! 광종의 누런 깃발을 오늘 모두 뽑아 버립시다. 뒤돌아볼 틈 없이 밀고 들어가겠소!' + text: '좋소! 형님 깃발이 보이는 거리에서 동쪽 숲길부터 열겠소. 우리가 지휘진을 흔들면 관군의 포위망도 한꺼번에 조여들 것이오!' } ]; @@ -670,7 +670,7 @@ export const fourthBattleVictoryPages: StoryPage[] = [ bgm: 'militia-theme', chapter: '황건적 토벌', background: 'story-yellow-pursuit', - text: '광종의 들판에서 황건의 본영이 무너졌다. 피난민들은 오래 닫았던 문을 열고, 쓰러진 깃발 너머로 관군과 의용군을 바라보았다.' + text: '의용군이 동쪽 지휘선을 끊자 관군의 포위망이 맞물렸고 광종의 황건 본대는 와해되었다. 피난민들은 오래 닫았던 문을 열고, 쓰러진 깃발 너머의 관군과 의용군을 바라보았다.' }, { id: 'fourth-victory-liu-bei', @@ -681,12 +681,19 @@ export const fourthBattleVictoryPages: StoryPage[] = [ portrait: 'liuBei', text: '난은 잠시 가라앉았지만, 천하가 바로 선 것은 아니다. 오늘의 공이 교만이 되지 않도록 마음을 다잡자.' }, + { + id: 'fourth-victory-lu-zhi-recalled', + bgm: 'battle-prep', + chapter: '뒤집힌 군령', + background: 'story-yellow-pursuit', + text: '승전 장부의 먹이 마르기도 전에 조정의 사자가 도착했다. 포위망을 이끌던 노식은 낙양으로 소환되고, 남은 군권에는 동탁의 이름이 올랐다. 세 형제는 전장의 승리만으로 어지러운 조정을 바로잡을 수 없음을 처음 실감했다.' + }, { id: 'fourth-victory-next-era', bgm: 'battle-prep', chapter: '새로운 혼란', background: 'story-yellow-pursuit', - text: '황건의 불길이 잦아들 무렵, 조정 안에서는 또 다른 그림자가 자라났다. 동탁의 폭정에 맞서 제후들이 격문을 돌리기 시작했고, 세 형제에게도 그 소식이 닿았다.' + text: '황건의 불길이 잦아든 뒤에도 동탁의 권세는 멈추지 않았다. 마침내 제후들이 폭정에 맞설 격문을 돌리기 시작했고, 광종에서 그 이름을 먼저 들었던 세 형제에게도 소식이 닿았다.' } ]; diff --git a/src/game/data/thirdCampExploration.ts b/src/game/data/thirdCampExploration.ts new file mode 100644 index 0000000..163a3ba --- /dev/null +++ b/src/game/data/thirdCampExploration.ts @@ -0,0 +1,583 @@ +import type { + ExplorationCharacterDirection, + ExplorationCharacterTextureKey +} from './explorationCharacterAssets'; +import { + resolveThirdBattlePriorityReturnDialogue, + thirdBattleReturnSourceBattleId, + type ThirdBattlePriorityReturnDialogue, + type ThirdBattleReturnCampaignState, + type ThirdBattleReturnTargetDialogueId +} from './thirdBattleReturnDialogue'; + +export const thirdCampExplorationVisitId = + 'third-guangzong-sortie-camp'; +export const thirdCampExplorationSourceBattleId = + thirdBattleReturnSourceBattleId; +export const thirdCampExplorationTargetBattleId = + 'fourth-battle-guangzong-camp'; +export const thirdCampExplorationActivityIds = [ + 'information', + 'equipment', + 'companion' +] as const; + +export type ThirdCampExplorationActivityId = + (typeof thirdCampExplorationActivityIds)[number]; + +export type ThirdCampExplorationDialogueLine = { + readonly speaker: string; + readonly text: string; + readonly portraitKey?: string; +}; + +export type ThirdCampCompanionDialogueChoice = { + readonly id: string; + readonly label: string; + readonly response: string; + readonly rewardExp: number; +}; + +export type ThirdCampCompanionDialogueDefinition = { + readonly id: ThirdBattleReturnTargetDialogueId; + readonly title: string; + readonly availableAfterBattleIds: readonly [ + typeof thirdCampExplorationSourceBattleId + ]; + readonly unitIds: readonly [string, string]; + readonly bondId: string; + readonly rewardExp: number; + readonly lines: readonly string[]; + readonly choices: readonly ThirdCampCompanionDialogueChoice[]; +}; + +export type ThirdCampExplorationActorId = + | 'zou-jing-operations' + | 'quartermaster-equipment' + | 'companion-guan-yu' + | 'companion-zhang-fei'; + +export type ThirdCampExplorationActor = { + readonly id: ThirdCampExplorationActorId; + readonly name: string; + readonly role: string; + readonly textureKey: ExplorationCharacterTextureKey; + readonly portraitKey?: string; + readonly x: number; + readonly y: number; + readonly direction: ExplorationCharacterDirection; + readonly kind: 'activity' | 'companion'; + readonly activityId: ThirdCampExplorationActivityId; + readonly dialogue: readonly ThirdCampExplorationDialogueLine[]; + readonly repeatDialogue: readonly ThirdCampExplorationDialogueLine[]; + readonly completedDialogue: readonly ThirdCampExplorationDialogueLine[]; +}; + +export type ThirdCampExplorationActivityDefinition = { + readonly id: ThirdCampExplorationActivityId; + readonly label: string; + readonly shortLabel: string; + readonly detail: string; + readonly targetActorIds: readonly ThirdCampExplorationActorId[]; + readonly completionLine: string; + readonly worldCue: string; +}; + +export type ThirdCampExplorationDefinition = { + readonly id: typeof thirdCampExplorationVisitId; + readonly title: string; + readonly location: string; + readonly sourceBattleId: typeof thirdCampExplorationSourceBattleId; + readonly targetBattleId: typeof thirdCampExplorationTargetBattleId; + readonly heading: string; + readonly subtitle: string; + readonly description: string; + readonly background: { + readonly textureKey: 'third-guangzong-sortie-camp-background'; + readonly source: 'original-guangzong-forward-camp'; + }; + readonly introLines: readonly ThirdCampExplorationDialogueLine[]; + readonly movementBounds: { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + }; + readonly player: { + readonly textureKey: 'exploration-liu-bei'; + readonly x: number; + readonly y: number; + readonly direction: ExplorationCharacterDirection; + }; + readonly exit: { + readonly id: 'camp-exit'; + readonly name: string; + readonly x: number; + readonly y: number; + }; + readonly actors: readonly ThirdCampExplorationActor[]; + readonly activities: readonly ThirdCampExplorationActivityDefinition[]; + readonly resultDialogue: ThirdBattlePriorityReturnDialogue; + readonly companionDialogue: ThirdCampCompanionDialogueDefinition; +}; + +export const thirdCampCompanionDialogues = [ + { + id: 'liu-guan-after-guangzong-road', + title: '광종 길목의 침묵', + availableAfterBattleIds: [thirdCampExplorationSourceBattleId], + unitIds: ['liu-bei', 'guan-yu'], + bondId: 'liu-bei__guan-yu', + rewardExp: 16, + lines: [ + '유비: 광종으로 가는 길은 좁고, 적은 지형을 믿고 버티고 있소.', + '관우: 험한 길에서는 말보다 마음이 먼저 흔들립니다. 제가 앞에서 병사들의 숨을 고르겠습니다.', + '유비: 운장이 앞을 잡아 준다면, 나도 뒤에서 군의 뜻을 잃지 않게 하겠소.' + ], + choices: [ + { + id: 'lead-narrow-road', + label: '좁은 길의 선봉을 맡긴다', + response: '관우는 험지를 뚫는 임무를 조용히 받아들였다.', + rewardExp: 7 + }, + { + id: 'keep-discipline', + label: '군율 유지를 당부한다', + response: '관우는 승리보다 흐트러지지 않는 행군이 더 중요하다고 답했다.', + rewardExp: 5 + } + ] + }, + { + id: 'liu-zhang-after-guangzong-road', + title: '강가의 큰소리', + availableAfterBattleIds: [thirdCampExplorationSourceBattleId], + unitIds: ['liu-bei', 'zhang-fei'], + bondId: 'liu-bei__zhang-fei', + rewardExp: 16, + lines: [ + '장비: 강가 길목이 답답해서 창을 휘두를 맛이 덜했습니다.', + '유비: 답답한 싸움에서도 익덕이 버텨 주니 병사들이 물러서지 않았소.', + '장비: 다음엔 답답한 길도 제가 먼저 웃으며 열어 보이겠습니다.' + ], + choices: [ + { + id: 'laugh-through', + label: '호방함으로 사기를 올린다', + response: '장비의 큰 웃음이 군영의 무거운 공기를 풀었다.', + rewardExp: 6 + }, + { + id: 'guard-bridgehead', + label: '교두보 수비를 맡긴다', + response: '장비는 한 발도 물러서지 않는 수비 역시 선봉의 일이라며 고개를 끄덕였다.', + rewardExp: 5 + } + ] + }, + { + id: 'guan-zhang-after-guangzong-road', + title: '험지의 호흡', + availableAfterBattleIds: [thirdCampExplorationSourceBattleId], + unitIds: ['guan-yu', 'zhang-fei'], + bondId: 'guan-yu__zhang-fei', + rewardExp: 14, + lines: [ + '관우: 숲과 언덕에서는 한 걸음 늦어도 전열 전체가 늦어진다.', + '장비: 그럴 땐 형님이 길을 재고, 제가 길을 넓히면 되지 않겠습니까?', + '관우: 좋은 말이다. 칼이 재고 창이 연다면 험지도 길이 된다.' + ], + choices: [ + { + id: 'measure-and-break', + label: '관우가 재고 장비가 뚫는다', + response: '두 사람은 험지 돌파의 역할을 명확히 나누었다.', + rewardExp: 7 + }, + { + id: 'watch-each-other', + label: '서로의 후방을 살핀다', + response: '성급한 돌파보다 빈틈을 지우는 호흡이 깊어졌다.', + rewardExp: 5 + } + ] + } +] as const satisfies readonly ThirdCampCompanionDialogueDefinition[]; + +export function isThirdCampExplorationActivityId( + value: unknown +): value is ThirdCampExplorationActivityId { + return ( + typeof value === 'string' && + (thirdCampExplorationActivityIds as readonly string[]).includes( + value + ) + ); +} + +export function thirdCampExplorationActivityProgressId( + activityId: ThirdCampExplorationActivityId +) { + return `${thirdCampExplorationVisitId}:activity:${activityId}`; +} + +export function completedThirdCampExplorationActivityIds( + completedCampVisitIds: readonly string[] +) { + const completed = new Set(completedCampVisitIds); + return thirdCampExplorationActivityIds.filter((activityId) => + completed.has(thirdCampExplorationActivityProgressId(activityId)) + ); +} + +export function getThirdCampCompanionDialogue( + dialogueId: unknown +): ThirdCampCompanionDialogueDefinition | undefined { + if (typeof dialogueId !== 'string') { + return undefined; + } + return thirdCampCompanionDialogues.find( + (dialogue) => dialogue.id === dialogueId + ); +} + +export function resolveThirdCampCompanionDialogue( + campaign?: ThirdBattleReturnCampaignState | null +) { + const resultDialogue = + resolveThirdBattlePriorityReturnDialogue(campaign); + return resultDialogue + ? getThirdCampCompanionDialogue(resultDialogue.targetDialogueId) + : undefined; +} + +export function resolveThirdCampExplorationDefinition( + campaign?: ThirdBattleReturnCampaignState | null +): ThirdCampExplorationDefinition | undefined { + const resultDialogue = + resolveThirdBattlePriorityReturnDialogue(campaign); + const companionDialogue = resultDialogue + ? getThirdCampCompanionDialogue(resultDialogue.targetDialogueId) + : undefined; + if (!resultDialogue || !companionDialogue) { + return undefined; + } + + const companionActors = companionActorsFor(resultDialogue); + const equipment = equipmentCopyFor(resultDialogue); + const actors: ThirdCampExplorationActor[] = [ + { + id: 'zou-jing-operations', + name: '추정', + role: '동쪽 삼림 선봉 작전', + textureKey: 'exploration-zou-jing', + portraitKey: 'zouJing', + x: 505, + y: 405, + direction: 'east', + kind: 'activity', + activityId: 'information', + dialogue: [ + { + speaker: '추정', + portraitKey: 'zouJing', + text: '노식 중랑장의 주력은 광종을 넓게 포위한다. 우리는 동쪽 삼림을 지나 본영 바깥 선봉과 퇴로 표식을 먼저 확인한다. 적의 첫 움직임을 읽되 본대와 이어진 길은 비워 두게.' + }, + { + speaker: '유비', + portraitKey: 'liuBei', + text: '본대보다 앞서되 고립되지는 않겠습니다. 적 선봉의 움직임과 우리 병사들이 돌아올 길을 한 작전판에 표시하겠습니다.' + } + ], + repeatDialogue: [ + { + speaker: '추정', + portraitKey: 'zouJing', + text: '본영 동쪽 선봉의 이동 표식은 작전판에 남아 있다. 출전 준비에서 다른 방침을 골라도 확인한 정보는 사라지지 않는다.' + } + ], + completedDialogue: [ + { + speaker: '추정', + portraitKey: 'zouJing', + text: '적 선봉 하나의 첫 움직임을 표시했다. 이 정보를 우선하면 배치 때 의도와 표식을 먼저 볼 수 있다.' + } + ] + }, + { + id: 'quartermaster-equipment', + name: '의용군 군수관', + role: '동림 선봉 병기·방호구', + textureKey: 'exploration-zhuo-quartermaster', + portraitKey: 'zhuoQuartermaster', + x: 360, + y: 745, + direction: 'east', + kind: 'activity', + activityId: 'equipment', + dialogue: equipment.dialogue, + repeatDialogue: equipment.repeatDialogue, + completedDialogue: equipment.completedDialogue + }, + ...companionActors + ]; + + return { + id: thirdCampExplorationVisitId, + title: '광종 동림 출진 야영지', + location: '광종 동쪽 삼림 · 노식군 외곽 포위망', + sourceBattleId: thirdCampExplorationSourceBattleId, + targetBattleId: thirdCampExplorationTargetBattleId, + heading: '본영을 향한 세 가지 준비', + subtitle: + '노식군의 큰 포위망 안에서 의용군은 동쪽 삼림 선봉을 맡습니다. 필요한 준비만 확인하고 언제든 출진할 수 있습니다.', + description: + '작전판에서 적 선봉의 움직임을 읽고, 보급 수레에서 방호구를 손보거나, 먼저 돌아온 동료와 지난 전투를 되짚을 수 있습니다. 세 활동은 모두 선택 사항이며 어느 순서로 해도 됩니다. 활동을 마칠 때마다 그 준비가 자동으로 선택되고, 마지막으로 확인한 준비 하나만 다음 전투의 초반 효과가 됩니다.', + background: { + textureKey: 'third-guangzong-sortie-camp-background', + source: 'original-guangzong-forward-camp' + }, + introLines: [ + { + speaker: '추정', + portraitKey: 'zouJing', + text: '노식 중랑장의 본대가 광종 포위망을 좁히는 동안, 그대들의 의용군은 동쪽 삼림으로 나가 적 선봉과 본영 퇴로를 끊는다.' + }, + { + speaker: '유비', + portraitKey: 'liuBei', + text: '병사들이 무엇을 준비했는지 직접 살핀 뒤 출전하겠습니다. 서두르되 준비를 의무처럼 붙잡지는 않겠습니다.' + } + ], + movementBounds: { x: 42, y: 108, width: 1404, height: 814 }, + player: { + textureKey: 'exploration-liu-bei', + x: 930, + y: 850, + direction: 'north' + }, + exit: { + id: 'camp-exit', + name: '군영 출진 장부로 돌아가기', + x: 790, + y: 914 + }, + actors, + activities: [ + { + id: 'information', + label: '추정과 본영 선봉 작전 확인', + shortLabel: '본영 정보', + detail: + '노식군의 포위망과 의용군의 동쪽 삼림 진입로를 겹쳐 적 선봉의 첫 움직임을 표시합니다.', + targetActorIds: ['zou-jing-operations'], + completionLine: + '작전판에 적 선봉의 의도와 동쪽 삼림 퇴로가 함께 표시되었습니다.', + worldCue: '작전판에 적 선봉·동림 퇴로 표식' + }, + { + id: 'equipment', + label: '군수관과 선봉 방호구 정비', + shortLabel: '장비 정비', + detail: equipment.activityDetail, + targetActorIds: ['quartermaster-equipment'], + completionLine: + '보급 수레에 수선한 방호구와 응급 천이 출전 순서대로 실렸습니다.', + worldCue: '보급 수레에 방패·응급 천 적재' + }, + { + id: 'companion', + label: `${companionDialogue.title} 되짚기`, + shortLabel: '동료 공명', + detail: + '지난 전투 결과에 가장 먼저 반응한 동료와 다음 돌파의 호흡을 정합니다.', + targetActorIds: companionActors.map((actor) => actor.id), + completionLine: + '대화에서 정한 짧은 신호가 다음 본영전의 초반 협공 약속으로 남았습니다.', + worldCue: '동료와 협공 신호 공유' + } + ], + resultDialogue, + companionDialogue + }; +} + +function companionActorsFor( + resultDialogue: ThirdBattlePriorityReturnDialogue +): ThirdCampExplorationActor[] { + const dialogue = resultDialogue.lines.map(resultDialogueLine); + const completedDialogue = [ + { + speaker: '유비', + portraitKey: 'liuBei', + text: '방금 맞춘 호흡은 출전 대열에도 전해 두었소. 다른 준비를 살펴도 이 약속은 남아 있을 것이오.' + } + ]; + + if (resultDialogue.variantId === 'fort-recovery') { + return [ + companionActor({ + id: 'companion-guan-yu', + name: '관우', + role: '강가 요새 회복책', + textureKey: 'exploration-guan-yu', + portraitKey: 'guanYu', + x: 1075, + y: 510, + direction: 'west', + dialogue, + completedDialogue + }) + ]; + } + if (resultDialogue.variantId === 'tempo-recovery') { + return [ + companionActor({ + id: 'companion-zhang-fei', + name: '장비', + role: '본영 돌파 호흡', + textureKey: 'exploration-zhang-fei', + portraitKey: 'zhangFei', + x: 1115, + y: 560, + direction: 'west', + dialogue, + completedDialogue + }) + ]; + } + return [ + companionActor({ + id: 'companion-guan-yu', + name: '관우', + role: '동림 길목 판단', + textureKey: 'exploration-guan-yu', + portraitKey: 'guanYu', + x: 1035, + y: 500, + direction: 'east', + dialogue, + completedDialogue + }), + companionActor({ + id: 'companion-zhang-fei', + name: '장비', + role: '동림 돌파 선봉', + textureKey: 'exploration-zhang-fei', + portraitKey: 'zhangFei', + x: 1240, + y: 535, + direction: 'west', + dialogue, + completedDialogue + }) + ]; +} + +function companionActor( + options: Omit< + ThirdCampExplorationActor, + 'kind' | 'activityId' | 'repeatDialogue' + > +): ThirdCampExplorationActor { + return { + ...options, + kind: 'companion', + activityId: 'companion', + repeatDialogue: options.completedDialogue + }; +} + +function resultDialogueLine(line: string): ThirdCampExplorationDialogueLine { + const separator = line.indexOf(':'); + if (separator <= 0) { + return { speaker: '동료', text: line }; + } + const speaker = line.slice(0, separator).trim(); + return { + speaker, + text: line.slice(separator + 1).trim(), + ...(portraitKeyForSpeaker(speaker) + ? { portraitKey: portraitKeyForSpeaker(speaker) } + : {}) + }; +} + +function portraitKeyForSpeaker(speaker: string) { + const portraitKeys: Readonly> = { + 유비: 'liuBei', + 관우: 'guanYu', + 장비: 'zhangFei', + 추정: 'zouJing', + '의용군 군수관': 'zhuoQuartermaster' + }; + return portraitKeys[speaker]; +} + +function equipmentCopyFor( + resultDialogue: ThirdBattlePriorityReturnDialogue +) { + if (resultDialogue.fortAchieved) { + return { + activityDetail: + '강가 요새에서 회수한 온전한 방패와 마른 결속끈을 동쪽 삼림 선봉의 첫 피격에 맞춰 배분합니다.', + dialogue: [ + { + speaker: '의용군 군수관', + portraitKey: 'zhuoQuartermaster', + text: '강가 요새를 지킨 덕에 마른 결속끈과 온전한 방패를 회수했습니다. 동쪽 숲의 낮은 가지에 걸리지 않게 방패끈부터 줄이겠습니다.' + }, + { + speaker: '유비', + portraitKey: 'liuBei', + text: '첫 충돌을 버틴 뒤 곧바로 대열을 되찾을 수 있게 앞줄부터 나누어 주시오. 무거운 물자는 본대 수레에 남깁시다.' + } + ], + repeatDialogue: [ + { + speaker: '의용군 군수관', + portraitKey: 'zhuoQuartermaster', + text: '요새에서 회수한 방패와 응급 천은 선봉 순서대로 실었습니다. 다른 준비를 골라도 정비한 장비는 그대로 남습니다.' + } + ], + completedDialogue: [ + { + speaker: '의용군 군수관', + portraitKey: 'zhuoQuartermaster', + text: '첫 피격을 한 번 흘릴 방호구를 앞줄에 배분했습니다.' + } + ] + } as const; + } + return { + activityDetail: + '강가 요새를 남긴 퇴각에서 젖고 상한 방호구를 골라 수선하고, 동쪽 삼림에서 첫 충돌을 버틸 최소 장비를 마련합니다.', + dialogue: [ + { + speaker: '의용군 군수관', + portraitKey: 'zhuoQuartermaster', + text: '강가 요새를 두고 물러오며 방패끈이 젖고 갑옷 몇 벌이 상했습니다. 멀쩡한 조각을 골라 앞줄 방호구부터 다시 묶겠습니다.' + }, + { + speaker: '유비', + portraitKey: 'liuBei', + text: '새 장비처럼 보이게 하는 것보다 첫 충돌에서 끊어지지 않는 것이 중요하오. 수선한 장비와 응급 천을 같은 수레에 실어 주시오.' + } + ], + repeatDialogue: [ + { + speaker: '의용군 군수관', + portraitKey: 'zhuoQuartermaster', + text: '젖은 끈은 모두 갈고 상한 방호구는 겹쳐 묶었습니다. 다른 준비를 골라도 수선한 장비는 그대로 남습니다.' + } + ], + completedDialogue: [ + { + speaker: '의용군 군수관', + portraitKey: 'zhuoQuartermaster', + text: '수선한 방호구가 첫 충돌을 한 번 버틸 만큼은 갖춰졌습니다.' + } + ] + } as const; +} diff --git a/src/game/data/thirdCampPreparation.ts b/src/game/data/thirdCampPreparation.ts index 086c7cd..d333b4a 100644 --- a/src/game/data/thirdCampPreparation.ts +++ b/src/game/data/thirdCampPreparation.ts @@ -2,6 +2,11 @@ import { resolveThirdBattlePriorityReturnDialogue, thirdBattleReturnSourceBattleId } from './thirdBattleReturnDialogue'; +import { + completedThirdCampExplorationActivityIds, + thirdCampExplorationVisitId, + type ThirdCampExplorationActivityId +} from './thirdCampExploration'; export const thirdCampPreparationSourceBattleId = thirdBattleReturnSourceBattleId; @@ -135,6 +140,24 @@ export const thirdCampPreparationPriorities: readonly ] as const; export type ThirdCampPreparationEvidence = + | { + kind: 'exploration-activity'; + battleId: typeof thirdCampPreparationSourceBattleId; + visitId: typeof thirdCampExplorationVisitId; + activityId: Exclude< + ThirdCampExplorationActivityId, + 'companion' + >; + } + | { + kind: 'exploration-activity'; + battleId: typeof thirdCampPreparationSourceBattleId; + visitId: typeof thirdCampExplorationVisitId; + activityId: 'companion'; + dialogueId: string; + bondId: string; + unitIds: [string, string]; + } | { kind: 'victory-reward-category'; battleId: typeof thirdCampPreparationSourceBattleId; @@ -208,6 +231,7 @@ export type ThirdCampPreparationCampaignState = { >; firstBattleReport?: ThirdCampPreparationBattleRecord | null; completedCampDialogues?: readonly unknown[]; + completedCampVisits?: readonly unknown[]; thirdCampPreparationSelection?: { sourceBattleId?: unknown; targetBattleId?: unknown; @@ -288,15 +312,19 @@ export function resolveThirdCampPreparationAvailability( })); } - const informationEvidence = acknowledgedRewardEvidence( - campaign, - 'unlocks' - ); - const equipmentEvidence = acknowledgedRewardEvidence( - campaign, - 'equipment' - ); - const companionEvidence = completedCompanionEvidence(campaign); + const informationEvidence = + completedExplorationEvidence(campaign, 'information') ?? + selectedLegacyRewardEvidence(campaign, 'information', 'unlocks'); + const equipmentEvidence = + completedExplorationEvidence(campaign, 'equipment') ?? + selectedLegacyRewardEvidence( + campaign, + 'equipment', + 'equipment' + ); + const companionEvidence = + completedExplorationEvidence(campaign, 'companion') ?? + completedCompanionEvidence(campaign); const evidenceByPriority: Partial< Record > = { @@ -515,6 +543,70 @@ function acknowledgedRewardEvidence( : undefined; } +function selectedLegacyRewardEvidence( + campaign: ThirdCampPreparationCampaignState | null | undefined, + priorityId: Extract< + ThirdCampPreparationPriorityId, + 'information' | 'equipment' + >, + category: 'unlocks' | 'equipment' +) { + const selection = campaign?.thirdCampPreparationSelection; + if ( + selection?.sourceBattleId !== + thirdCampPreparationSourceBattleId || + selection.targetBattleId !== + thirdCampPreparationTargetBattleId || + selection.priorityId !== priorityId + ) { + return undefined; + } + return acknowledgedRewardEvidence(campaign, category); +} + +function completedExplorationEvidence( + campaign: ThirdCampPreparationCampaignState | null | undefined, + activityId: ThirdCampExplorationActivityId +): Extract< + ThirdCampPreparationEvidence, + { kind: 'exploration-activity' } +> | undefined { + const completedActivityIds = + completedThirdCampExplorationActivityIds( + arrayValue(campaign?.completedCampVisits).filter( + (visitId): visitId is string => typeof visitId === 'string' + ) + ); + if (!completedActivityIds.includes(activityId)) { + return undefined; + } + if (activityId !== 'companion') { + return { + kind: 'exploration-activity', + battleId: thirdCampPreparationSourceBattleId, + visitId: thirdCampExplorationVisitId, + activityId + }; + } + + const dialogue = + resolveThirdBattlePriorityReturnDialogue(campaign); + const bond = dialogue + ? companionBondByDialogueId[dialogue.targetDialogueId] + : undefined; + return dialogue && bond + ? { + kind: 'exploration-activity', + battleId: thirdCampPreparationSourceBattleId, + visitId: thirdCampExplorationVisitId, + activityId: 'companion', + dialogueId: dialogue.targetDialogueId, + bondId: bond.bondId, + unitIds: [...bond.unitIds] + } + : undefined; +} + function completedCompanionEvidence( campaign?: ThirdCampPreparationCampaignState | null ): Extract< @@ -548,21 +640,33 @@ function resolveEffect( ): ResolvedThirdCampPreparationEffect | undefined { if ( effect.kind === 'marked-vanguard-intel' && - evidence.kind === 'victory-reward-category' && - evidence.category === 'unlocks' + ( + (evidence.kind === 'exploration-activity' && + evidence.activityId === 'information') || + (evidence.kind === 'victory-reward-category' && + evidence.category === 'unlocks') + ) ) { return { ...effect }; } if ( effect.kind === 'prepared-equipment-guard' && - evidence.kind === 'victory-reward-category' && - evidence.category === 'equipment' + ( + (evidence.kind === 'exploration-activity' && + evidence.activityId === 'equipment') || + (evidence.kind === 'victory-reward-category' && + evidence.category === 'equipment') + ) ) { return { ...effect }; } if ( effect.kind === 'return-bond-opening' && - evidence.kind === 'priority-return-dialogue' + ( + evidence.kind === 'priority-return-dialogue' || + (evidence.kind === 'exploration-activity' && + evidence.activityId === 'companion') + ) ) { return { ...effect, @@ -604,7 +708,11 @@ function clonePriority( function cloneEvidence( evidence: ThirdCampPreparationEvidence ): ThirdCampPreparationEvidence { - if (evidence.kind === 'priority-return-dialogue') { + if ( + evidence.kind === 'priority-return-dialogue' || + (evidence.kind === 'exploration-activity' && + evidence.activityId === 'companion') + ) { return { ...evidence, unitIds: [...evidence.unitIds] diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index abb7de9..710391e 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -49,6 +49,11 @@ import { thirdBattleReturnCampStep, thirdBattleReturnSourceBattleId } from '../data/thirdBattleReturnDialogue'; +import { + resolveThirdCampExplorationDefinition, + thirdCampCompanionDialogues, + thirdCampExplorationVisitId +} from '../data/thirdCampExploration'; import { thirdCampPreparationSourceBattleId, thirdCampPreparationTargetBattleId, @@ -256,9 +261,11 @@ import { } from '../state/cityStayActions'; import { secondBattleReliefProgress } from '../state/secondBattleReliefActions'; import { + clearThirdCampPreparationPriority, setThirdCampPreparationPriority, thirdCampPreparationProgress } from '../state/thirdCampPreparationActions'; +import { thirdCampExplorationProgress } from '../state/thirdCampExplorationActions'; import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; @@ -331,7 +338,8 @@ type CampVisitChoice = { const directExplorationVisitIds = new Set([ firstPursuitScoutVisitId, - secondBattleReliefVisitId + secondBattleReliefVisitId, + thirdCampExplorationVisitId ]); type CampTabButtonView = { @@ -1327,7 +1335,7 @@ const sortieRulesByBattleId: Partial ({ + ...dialogue, + availableAfterBattleIds: [...dialogue.availableAfterBattleIds], + unitIds: [...dialogue.unitIds] as [string, string], + lines: [...dialogue.lines], + choices: dialogue.choices.map((choice) => ({ ...choice })) + })), { id: 'liu-guan-after-zhang-jue', title: '황건의 깃발이 꺾인 밤', @@ -8508,6 +8442,20 @@ const campVisits: CampVisitDefinition[] = [ }) ) }, + { + id: thirdCampExplorationVisitId, + title: '광종 동림 출진 야영지', + location: '광종 동쪽 삼림 · 노식군 외곽 포위망', + availableAfterBattleIds: [thirdCampPreparationSourceBattleId], + availableDuringSteps: [thirdBattleReturnCampStep], + description: + '광종 본영으로 나가기 전, 작전판·보급 수레·먼저 돌아온 동료를 직접 찾아 준비합니다.', + lines: [ + '추정: 노식 중랑장의 본대가 포위망을 좁히는 동안 의용군은 동쪽 삼림 선봉을 맡는다.', + '유비: 필요한 준비를 직접 살피되, 준비를 의무처럼 붙잡지는 않겠습니다.' + ], + choices: [] + }, { id: 'jingzhou-scholar-rumors', title: '형주 선비들의 모임', @@ -11489,6 +11437,7 @@ export class CampScene extends Phaser.Scene { private selectedVisitId = campVisits[0].id; private firstPursuitExplorationButton?: Phaser.GameObjects.Rectangle; private secondBattleReliefExplorationButton?: Phaser.GameObjects.Rectangle; + private thirdCampExplorationButton?: Phaser.GameObjects.Rectangle; private equipmentInventoryPage = 0; private equipmentPanelBackground?: Phaser.GameObjects.Rectangle; private equipmentInventoryPreviousButton?: Phaser.GameObjects.Rectangle; @@ -11550,6 +11499,7 @@ export class CampScene extends Phaser.Scene { private thirdCampPreparationBrowserOpen = false; private thirdCampPreparationToggleButton?: Phaser.GameObjects.Rectangle; private thirdCampPreparationCloseButton?: Phaser.GameObjects.Rectangle; + private thirdCampPreparationClearButton?: Phaser.GameObjects.Rectangle; private thirdCampPreparationCardViews: ThirdCampPreparationCardView[] = []; private visitedTabs = new Set(); private selectedSortieUnitIds: string[] = []; @@ -11636,6 +11586,7 @@ export class CampScene extends Phaser.Scene { this.campDialogueBodyText = undefined; this.firstPursuitExplorationButton = undefined; this.secondBattleReliefExplorationButton = undefined; + this.thirdCampExplorationButton = undefined; this.dialogueObjects = []; this.sortieObjects = []; this.sortieComparisonPanelView = undefined; @@ -11763,10 +11714,16 @@ export class CampScene extends Phaser.Scene { ? 'dialogue' : 'market'; } + const thirdExplorationProgress = + this.campaign.step === thirdBattleReturnCampStep + ? thirdCampExplorationProgress(this.campaign) + : undefined; const pendingDirectExplorationVisit = this.availableCampVisits().find( (visit) => directExplorationVisitIds.has(visit.id) && - !this.completedCampVisits().includes(visit.id) + (visit.id === thirdCampExplorationVisitId + ? !thirdExplorationProgress?.entered + : !this.completedCampVisits().includes(visit.id)) ); if (!cityStay && pendingDirectExplorationVisit) { this.selectedVisitId = pendingDirectExplorationVisit.id; @@ -13180,7 +13137,34 @@ export class CampScene extends Phaser.Scene { private availableCampVisits() { const battleId = this.currentCampBattleId(); - return this.filterCampEventsByStep(campVisits.filter((visit) => visit.availableAfterBattleIds.includes(battleId))); + return this.filterCampEventsByStep( + campVisits + .filter((visit) => visit.availableAfterBattleIds.includes(battleId)) + .map((visit) => this.campVisitWithThirdCampExploration(visit)) + ); + } + + private campVisitWithThirdCampExploration( + visit: CampVisitDefinition + ): CampVisitDefinition { + if (visit.id !== thirdCampExplorationVisitId) { + return visit; + } + const definition = resolveThirdCampExplorationDefinition( + this.campaign + ); + if (!definition) { + return visit; + } + return { + ...visit, + title: definition.title, + location: definition.location, + description: definition.description, + lines: definition.introLines.map( + (line) => `${line.speaker}: ${line.text}` + ) + }; } private completedAvailableVisits() { @@ -14667,7 +14651,7 @@ export class CampScene extends Phaser.Scene { const commandTitle = thirdCampPreparation ? thirdCampPreparation.ready ? `출진 우선 · ${thirdCampPreparation.selectedLabel}` - : '출진 우선 · 하나를 선택' + : '출진 우선 · 보너스 없음' : firstBattleFollowup ? `지난 전투 인계 · ${firstBattleFollowup.mentorName}` : '지휘관 판단'; @@ -14677,7 +14661,7 @@ export class CampScene extends Phaser.Scene { ); const commandSummary = thirdCampPreparation ? selectedPreparation?.priority.summary ?? - '실제로 확인한 정보·장비·동료 활동 가운데 이번 출진에 먼저 반영할 하나를 정하십시오.' + '야영지에서 완료한 활동은 보너스 하나로 선택할 수 있습니다. 선택하지 않고 출진해도 됩니다.' : firstBattleFollowup ? firstBattleFollowup.achieved ? `${firstBattleFollowup.originalOrderLabel} 완수 → ${firstBattleFollowup.recommendedNextOrderLabel} · ${firstBattleFollowup.focusCriterionLabel}` @@ -14700,7 +14684,7 @@ export class CampScene extends Phaser.Scene { this.renderThirdCampPreparationToggle( x + width - 86, commandY + 51, - thirdCampPreparation.ready ? '준비 변경' : '준비 선택', + thirdCampPreparation.ready ? '준비 변경' : '보너스 선택', depth + 2 ); } else if (firstBattleFollowup) { @@ -14787,13 +14771,13 @@ export class CampScene extends Phaser.Scene { background.setStrokeStyle(1, palette.gold, 0.72); this.trackSortie( - this.add.text(x + 18, y + 14, '이번 출진의 우선 준비', this.textStyle(19, '#f2e3bf', true)) + this.add.text(x + 18, y + 14, '이번 출진의 선택 보너스', this.textStyle(19, '#f2e3bf', true)) ).setDepth(depth + 1); this.trackSortie( this.add.text( x + 18, y + 41, - '실제로 확인한 활동 하나를 광종 본영전 첫 3턴에 반영합니다.', + '야영지에서 완료한 활동 중 하나만 첫 3턴에 반영합니다. 미선택 출진도 가능합니다.', this.textStyle(10, '#9fb0bf') ) ).setDepth(depth + 1); @@ -14960,37 +14944,75 @@ export class CampScene extends Phaser.Scene { this.add.text( x + 18, y + height - 25, - '권장 선택 · 출진 전에는 언제든 다른 완료 활동으로 바꿀 수 있습니다.', - this.textStyle(10, progress.ready ? '#a8ffd0' : '#ffdf7b', true) + progress.ready + ? '선택 사항 · 해제하거나 다른 완료 활동으로 바꿀 수 있습니다.' + : '선택 사항 · 보너스 없이 그대로 출진할 수 있습니다.', + { + ...this.textStyle( + 10, + progress.ready ? '#a8ffd0' : '#9fb0bf', + true + ), + wordWrap: { + width: progress.ready ? width - 158 : width - 36, + useAdvancedWrap: true + } + } ) ).setDepth(depth + 1); + + if (progress.selectedPriorityId) { + const clearX = x + width - 70; + const clearY = y + height - 24; + const clear = this.trackSortie( + this.add.rectangle(clearX, clearY, 112, 26, 0x1a2630, 0.96) + ); + clear.setDepth(depth + 2); + clear.setStrokeStyle(1, palette.blue, 0.72); + clear.setInteractive({ useHandCursor: true }); + clear.on('pointerover', () => clear.setFillStyle(0x283947, 1)); + clear.on('pointerout', () => clear.setFillStyle(0x1a2630, 0.96)); + clear.on('pointerdown', () => this.clearThirdCampPreparationSelection()); + this.thirdCampPreparationClearButton = clear; + const clearLabel = this.trackSortie( + this.add.text( + clearX, + clearY, + '선택 해제', + this.textStyle(10, '#d4dce6', true) + ) + ); + clearLabel.setOrigin(0.5); + clearLabel.setDepth(depth + 3); + clearLabel.setInteractive({ useHandCursor: true }); + clearLabel.on('pointerover', () => clear.setFillStyle(0x283947, 1)); + clearLabel.on('pointerout', () => clear.setFillStyle(0x1a2630, 0.96)); + clearLabel.on( + 'pointerdown', + () => this.clearThirdCampPreparationSelection() + ); + } } private thirdCampPreparationUnavailableLabel( reason?: ThirdCampPreparationUnavailableReason ) { if (reason === 'report-review-required') { - return '승전 보고에서 광종 본영 진입 정보를 먼저 확인해야 합니다.'; + return '야영지에서 추정과 작전판을 먼저 확인하면 선택할 수 있습니다.'; } if (reason === 'equipment-change-required') { - return '3차 승리로 받은 장비를 장비 탭에서 먼저 확인해야 합니다.'; + return '야영지 보급 수레에서 선봉 방호구를 정비하면 선택할 수 있습니다.'; } if (reason === 'priority-dialogue-required') { - return '전투 결과에 맞춰 표시된 우선 귀환 대화를 먼저 마쳐야 합니다.'; + return '야영지에서 먼저 돌아온 동료와 결과 대화를 나누면 선택할 수 있습니다.'; } return '광종 구원로 승리 기록이 있어야 선택할 수 있습니다.'; } private thirdCampPreparationLockedActionLabel( - priorityId: ThirdCampPreparationPriorityId + _priorityId: ThirdCampPreparationPriorityId ) { - if (priorityId === 'equipment') { - return '장비 보기'; - } - if (priorityId === 'companion') { - return '대화하기'; - } - return '보고 확인'; + return '야영지로'; } private selectThirdCampPreparationPriority( @@ -15014,41 +15036,35 @@ export class CampScene extends Phaser.Scene { this.showSortiePrep(); } - private openThirdCampPreparationActivity( - priorityId: ThirdCampPreparationPriorityId - ) { - if (priorityId === 'equipment') { - this.thirdCampPreparationBrowserOpen = false; - this.hideSortiePrep(); - this.activeTab = 'equipment'; - this.render(); - this.showCampNotice('3차 승전 장비를 확인했습니다. 출진 준비에서 장비 정비를 선택할 수 있습니다.'); + private clearThirdCampPreparationSelection() { + const result = clearThirdCampPreparationPriority(); + if (!result.ok) { + this.showCampNotice( + '현재 진행에서는 선택 보너스를 해제할 수 없습니다.' + ); return; } - - if (priorityId === 'companion') { - const priorityDialogue = this.thirdBattlePriorityReturnDialogue(); - if (!priorityDialogue) { - this.showCampNotice('현재 승리 기록에서 우선 귀환 대화를 확인할 수 없습니다.'); - return; - } - this.thirdCampPreparationBrowserOpen = false; - this.hideSortiePrep(); - this.activeTab = 'dialogue'; - this.selectedDialogueId = priorityDialogue.targetDialogueId; - this.render(); - this.showCampNotice('표시된 우선 귀환 대화를 마치면 동료 공명을 선택할 수 있습니다.'); - return; - } - - this.acknowledgePendingVictoryRewardCategories(['unlocks']); - this.campaign = getCampaignState(); + this.campaign = result.campaign; this.thirdCampPreparationBrowserOpen = true; soundDirector.playSelect(); - this.showCampNotice('승전 보고의 광종 본영 진입 정보를 확인했습니다.'); + this.showCampNotice( + result.changed + ? '선택 보너스를 해제했습니다. 보너스 없이 출진할 수 있습니다.' + : '현재 선택된 출진 보너스가 없습니다.' + ); this.showSortiePrep(); } + private openThirdCampPreparationActivity( + _priorityId: ThirdCampPreparationPriorityId + ) { + soundDirector.playSelect(); + this.thirdCampPreparationBrowserOpen = false; + this.startCampNavigation('CampVisitExplorationScene', { + visitId: thirdCampExplorationVisitId + }); + } + private openFirstBattleFollowupOrderBrowser() { if (!this.firstBattleCampFollowup() || !this.nextSortieScenario()) { return; @@ -20863,10 +20879,10 @@ export class CampScene extends Phaser.Scene { ...(thirdCampPreparation ? [{ label: '출진 우선 준비', - complete: thirdCampPreparation.ready, + complete: true, detail: thirdCampPreparation.ready ? `${thirdCampPreparation.selectedLabel} · 첫 3턴 반영` - : '정보·장비·동료 중 완료한 활동 하나 선택', + : '미선택 · 보너스 없이 출진 가능', priority: 'recommended' as const }] : []), @@ -21242,6 +21258,7 @@ export class CampScene extends Phaser.Scene { this.firstBattleFollowupSummaryText = undefined; this.thirdCampPreparationToggleButton = undefined; this.thirdCampPreparationCloseButton = undefined; + this.thirdCampPreparationClearButton = undefined; this.thirdCampPreparationCardViews = []; this.sortiePortraitRosterLayout = undefined; this.sortieRosterToggleBounds = []; @@ -24087,10 +24104,19 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setStrokeStyle(1, palette.gold, 0.58); const locationLabel = this.currentCityStay()?.city.name ?? this.campSkinSelection?.skin.locationLabel ?? '현지'; - this.track(this.add.text(x + 24, y + 22, `${locationLabel} 방문`, this.textStyle(24, '#f2e3bf', true))); - this.track(this.add.text(x + 24, y + 56, '획득한 정보·보급·공명은 출진 준비와 다음 전투로 이어집니다. 방문은 한 번만 완료됩니다.', this.textStyle(14, '#d4dce6'))); - const visits = this.availableCampVisits(); + const hasThirdCampExploration = visits.some( + (visit) => visit.id === thirdCampExplorationVisitId + ); + this.track(this.add.text(x + 24, y + 22, `${locationLabel} 방문`, this.textStyle(24, '#f2e3bf', true))); + this.track(this.add.text( + x + 24, + y + 56, + hasThirdCampExploration + ? '야영지는 언제든 다시 들어갈 수 있습니다. 모든 활동은 선택 사항이며 보너스 없이 바로 출진해도 됩니다.' + : '획득한 정보·보급·공명은 출진 준비와 다음 전투로 이어집니다. 방문은 한 번만 완료됩니다.', + this.textStyle(14, '#d4dce6') + )); if (!visits.some((visit) => visit.id === this.selectedVisitId)) { this.selectedVisitId = visits[0]?.id ?? campVisits[0].id; } @@ -24153,6 +24179,11 @@ export class CampScene extends Phaser.Scene { }) ); + if (visit.id === thirdCampExplorationVisitId) { + this.renderThirdCampExplorationVisit(x, y, width, height); + return; + } + if (completed) { const chosen = this.recordedVisitChoice(visit); if (chosen) { @@ -24255,6 +24286,105 @@ export class CampScene extends Phaser.Scene { }); } + private renderThirdCampExplorationVisit( + x: number, + y: number, + width: number, + height: number + ) { + const exploration = this.campaign + ? thirdCampExplorationProgress(this.campaign) + : undefined; + const preparation = this.campaign + ? thirdCampPreparationProgress(this.campaign) + : undefined; + const completedCount = exploration?.completedActivityCount ?? 0; + const activityCount = exploration?.activities.length ?? 3; + const guide = this.track( + this.add.rectangle( + x + 18, + y + height - 104, + width - 36, + 86, + 0x151f2a, + 0.96 + ) + ); + guide.setOrigin(0); + guide.setStrokeStyle( + 1, + completedCount > 0 ? palette.green : palette.blue, + 0.66 + ); + this.track( + this.add.text( + x + 32, + y + height - 92, + `준비 활동 ${completedCount}/${activityCount} · ${ + preparation?.selectedLabel + ? `선택 보너스: ${preparation.selectedLabel}` + : '보너스 미선택' + }`, + this.textStyle( + 12, + preparation?.selectedLabel ? '#a8ffd0' : '#d4dce6', + true + ) + ) + ); + this.track( + this.add.text( + x + 32, + y + height - 70, + '세 활동은 모두 선택 사항 · 군영으로 돌아와 보너스 없이 출진 가능', + this.textStyle(10, '#9fb0bf') + ) + ); + + const buttonY = y + height - 34; + const button = this.track( + this.add.rectangle( + x + width / 2, + buttonY, + width - 64, + 34, + 0x4a371d, + 0.98 + ) + ); + button.setStrokeStyle(2, palette.gold, 0.94); + button.setInteractive({ useHandCursor: true }); + const openExploration = () => { + soundDirector.playSelect(); + this.startCampNavigation('CampVisitExplorationScene', { + visitId: thirdCampExplorationVisitId + }); + }; + button.on('pointerover', () => button.setFillStyle(0x654c25, 1)); + button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98)); + button.on('pointerdown', openExploration); + this.thirdCampExplorationButton = button; + + const ctaLabel = !exploration?.entered + ? '야영지 탐색 시작' + : completedCount < activityCount + ? '야영지 탐색 계속' + : '야영지 다시 둘러보기'; + const label = this.track( + this.add.text( + x + width / 2, + buttonY, + ctaLabel, + this.textStyle(14, '#fff2b8', true) + ) + ); + label.setOrigin(0.5); + label.setInteractive({ useHandCursor: true }); + label.on('pointerover', () => button.setFillStyle(0x654c25, 1)); + label.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98)); + label.on('pointerdown', openExploration); + } + private renderVisitPreparationSummary(x: number, y: number, width: number) { const visits = this.availableCampVisits(); const completed = this.completedAvailableVisits(); @@ -24262,6 +24392,40 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setStrokeStyle(1, palette.blue, 0.42); this.track(this.add.text(x + 14, y + 12, '현지 준비 · 다음 전투 반영', this.textStyle(17, '#f2e3bf', true))); + if ( + visits.some((visit) => visit.id === thirdCampExplorationVisitId) && + this.campaign + ) { + const exploration = thirdCampExplorationProgress(this.campaign); + const preparation = thirdCampPreparationProgress(this.campaign); + this.track( + this.add.text( + x + 14, + y + 40, + `야영지 활동 ${exploration.completedActivityCount}/${exploration.activities.length}`, + this.textStyle( + 13, + exploration.completedActivityCount > 0 ? '#a8ffd0' : '#d4dce6', + true + ) + ) + ); + this.track( + this.add.text( + x + 14, + y + 66, + preparation.selectedLabel + ? `선택 보너스 · ${preparation.selectedLabel}` + : '보너스 미선택 · 그대로 출진 가능', + this.textStyle( + 13, + preparation.selectedLabel ? '#a8ffd0' : '#9fb0bf', + true + ) + ) + ); + return; + } this.track(this.add.text(x + 14, y + 40, `방문 ${completed.length}/${visits.length} 완료`, this.textStyle(13, completed.length >= visits.length && visits.length > 0 ? '#a8ffd0' : '#d4dce6', true))); const isWolongSearch = this.currentSortieFlow().nextBattleId === campBattleIds.seventeenth; const informationLabel = isWolongSearch @@ -26063,6 +26227,7 @@ export class CampScene extends Phaser.Scene { this.campDialogueBodyText = undefined; this.firstPursuitExplorationButton = undefined; this.secondBattleReliefExplorationButton = undefined; + this.thirdCampExplorationButton = undefined; this.campRosterLayout = undefined; this.cityPanelBackground = undefined; this.cityExploreButton = undefined; @@ -26234,6 +26399,12 @@ export class CampScene extends Phaser.Scene { : undefined; const secondReliefChoiceId = this.campaign?.campVisitChoiceIds[secondBattleReliefVisitId] ?? null; + const thirdCampExplorationVisit = this.availableCampVisits().find( + (visit) => visit.id === thirdCampExplorationVisitId + ); + const thirdCampExploration = this.campaign + ? thirdCampExplorationProgress(this.campaign) + : undefined; const thirdCampPreparation = this.campaign && this.isThirdCampPreparationSlice() ? thirdCampPreparationProgress(this.campaign) : undefined; @@ -26484,6 +26655,12 @@ export class CampScene extends Phaser.Scene { closeButtonBounds: this.sortieInteractiveObjectBoundsDebug( this.thirdCampPreparationCloseButton ), + clearButtonBounds: this.sortieInteractiveObjectBoundsDebug( + this.thirdCampPreparationClearButton + ), + clearAvailable: Boolean( + thirdCampPreparation.selectedPriorityId + ), priorities: thirdCampPreparation.availability.map((entry) => { const view = this.thirdCampPreparationCardViews.find( (candidate) => candidate.priorityId === entry.priority.id @@ -26523,6 +26700,8 @@ export class CampScene extends Phaser.Scene { browserOpen: false, toggleButtonBounds: null, closeButtonBounds: null, + clearButtonBounds: null, + clearAvailable: false, priorities: [] }, firstPursuitScoutMemory: { @@ -26586,6 +26765,42 @@ export class CampScene extends Phaser.Scene { rewardText: this.visitRewardText(choice) })) ?? [] }, + thirdCampExploration: { + visitId: thirdCampExplorationVisitId, + available: Boolean(thirdCampExplorationVisit), + selected: + this.selectedVisitId === thirdCampExplorationVisitId, + entered: thirdCampExploration?.entered ?? false, + completedActivityIds: + thirdCampExploration?.completedActivityIds ?? [], + completedActivityCount: + thirdCampExploration?.completedActivityCount ?? 0, + selectedPriorityId: + thirdCampExploration?.selectedPriorityId ?? null, + ready: thirdCampExploration?.ready ?? false, + mode: !thirdCampExplorationVisit + ? 'unavailable' + : !thirdCampExploration?.entered + ? 'explore' + : (thirdCampExploration.completedActivityCount ?? 0) < + (thirdCampExploration.activities.length || 3) + ? 'continue' + : 'revisit', + explorationButtonBounds: + this.sortieInteractiveObjectBoundsDebug( + this.thirdCampExplorationButton + ), + explorationInteractive: Boolean( + this.thirdCampExplorationButton?.input?.enabled + ), + activities: + thirdCampExploration?.activities.map((activity) => ({ + id: activity.id, + label: activity.label, + completed: activity.completed, + selected: activity.selected + })) ?? [] + }, selectedDialogue: selectedCampDialogue ? { id: selectedCampDialogue.id, diff --git a/src/game/scenes/CampVisitExplorationScene.ts b/src/game/scenes/CampVisitExplorationScene.ts index 76ec0e1..cb5d199 100644 --- a/src/game/scenes/CampVisitExplorationScene.ts +++ b/src/game/scenes/CampVisitExplorationScene.ts @@ -1,6 +1,7 @@ import Phaser from 'phaser'; import campBackgroundUrl from '../../assets/images/exploration/prologue-militia-camp.webp'; import secondReliefBackgroundUrl from '../../assets/images/exploration/second-pursuit-village-ferry.webp'; +import thirdCampBackgroundUrl from '../../assets/images/exploration/third-guangzong-sortie-camp.webp'; import { soundDirector } from '../audio/SoundDirector'; import { ensureExplorationCharacterAnimations, @@ -30,12 +31,23 @@ import { type SecondBattleReliefObjectiveId } from '../data/secondBattleReliefExploration'; import { resolveSecondBattleReliefContext } from '../data/secondBattleReliefMemory'; +import { + resolveThirdCampCompanionDialogue, + resolveThirdCampExplorationDefinition, + thirdCampExplorationVisitId, + type ThirdCampCompanionDialogueChoice, + type ThirdCampExplorationActivityId +} from '../data/thirdCampExploration'; +import { + getThirdCampPreparationPriority +} from '../data/thirdCampPreparation'; import { portraitAssetEntryForStoryContext, type PortraitAssetEntry } from '../data/portraitAssets'; import { ExplorationInputController } from '../input/ExplorationInputController'; import { releaseTextureSource } from '../render/loaderLifecycle'; +import { isVisualMotionReduced } from '../settings/visualMotion'; import { getCampaignState } from '../state/campaignState'; import { completeFirstPursuitScoutVisit } from '../state/firstPursuitScoutActions'; import { @@ -43,6 +55,12 @@ import { recordSecondBattleReliefObjective, secondBattleReliefProgress } from '../state/secondBattleReliefActions'; +import { + completeThirdCampCompanionActivity, + enterThirdCampExploration, + recordThirdCampExplorationActivity, + thirdCampExplorationProgress +} from '../state/thirdCampExplorationActions'; import { startGameScene } from './lazyScenes'; const sceneWidth = 1920; @@ -62,6 +80,7 @@ const directPointerSnapRadius = 96; const transitionFadeDurationMs = 320; const campBackgroundKey = 'first-pursuit-camp-background'; const secondReliefBackgroundKey = 'second-pursuit-village-ferry-background'; +const thirdCampBackgroundKey = 'third-guangzong-sortie-camp-background'; const playerTextureKey = explorationCharacterTextureKeyByUnitTextureKey['unit-liu-bei']; @@ -69,14 +88,7 @@ type CampVisitExplorationSceneData = { visitId?: string; }; -type ExplorationActorId = - | 'jian-yong' - | 'guan-yu' - | 'zhang-fei' - | 'guan-yu-village' - | 'north-ferry-boatman' - | 'zhang-fei-ferry' - | 'jian-yong-records'; +type ExplorationActorId = string; type ExplorationActorDefinition = { id: ExplorationActorId; @@ -87,10 +99,12 @@ type ExplorationActorDefinition = { x: number; y: number; direction: ExplorationCharacterDirection; - kind: 'objective' | 'companion'; + kind: 'objective' | 'activity' | 'companion'; objectiveId?: SecondBattleReliefObjectiveId; + activityId?: ThirdCampExplorationActivityId; dialogue: readonly DialogueLine[]; repeatDialogue?: readonly DialogueLine[]; + completedDialogue?: readonly DialogueLine[]; }; type ExplorationActorView = { @@ -132,7 +146,7 @@ type DialogueState = { }; type ChoiceView = { - choice: VisitChoice; + choice: ChoiceOption; button: Phaser.GameObjects.Rectangle; }; @@ -140,6 +154,9 @@ type VisitChoice = | FirstPursuitScoutVisitChoice | SecondBattleReliefChoice; +type ChoiceOption = VisitChoice | ThirdCampCompanionDialogueChoice; +type ChoicePanelMode = 'visit' | 'third-companion'; + const firstPursuitActorDefinitions: readonly ExplorationActorDefinition[] = [ { id: 'jian-yong', @@ -247,6 +264,10 @@ const secondReliefPortraitContext = { id: 'second-pursuit-aftermath-relief-exploration', background: 'story-camp' } as const; +const thirdCampPortraitContext = { + id: 'third-guangzong-sortie-camp-exploration', + background: 'story-camp' +} as const; const firstPursuitCampExit = { id: 'camp-exit' as const, name: '군영 장부로 돌아가기', @@ -257,8 +278,10 @@ function explorationPortraitEntries( portraitContext: | typeof firstPursuitPortraitContext | typeof secondReliefPortraitContext + | typeof thirdCampPortraitContext, + activePortraitKeys: readonly string[] = portraitKeys ) { - return [...new Set(portraitKeys)] + return [...new Set(activePortraitKeys)] .map((portraitKey) => portraitAssetEntryForStoryContext( portraitKey, @@ -284,6 +307,8 @@ export class CampVisitExplorationScene extends Phaser.Scene { private promptText?: Phaser.GameObjects.Text; private objectiveStatus?: Phaser.GameObjects.Text; private objectiveCard?: Phaser.GameObjects.Rectangle; + private thirdActivityTitleText?: Phaser.GameObjects.Text; + private thirdPriorityLocationText?: Phaser.GameObjects.Text; private progressText?: Phaser.GameObjects.Text; private dialoguePanel?: Phaser.GameObjects.Container; private dialoguePortraitFrame?: Phaser.GameObjects.Rectangle; @@ -295,7 +320,14 @@ export class CampVisitExplorationScene extends Phaser.Scene { private choicePanel?: Phaser.GameObjects.Container; private choicePanelBounds?: Phaser.Geom.Rectangle; private choiceViews: ChoiceView[] = []; + private choicePanelMode: ChoicePanelMode = 'visit'; + private choiceSourceNpcId?: ExplorationActorId; private choiceReadyAt = Number.POSITIVE_INFINITY; + private thirdWorldFeedback = new Map< + ThirdCampExplorationActivityId, + Phaser.GameObjects.Container + >(); + private thirdCampFirstEntry = false; private transitionOverlay?: Phaser.GameObjects.Container; private completionToast?: Phaser.GameObjects.Container; private explorationInput?: ExplorationInputController; @@ -312,6 +344,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { private lastStepAt = 0; private lastNotice = ''; private ownedPresentationTextureKeys = new Set(); + private visualMotionReduced = false; constructor() { super('CampVisitExplorationScene'); @@ -319,9 +352,11 @@ export class CampVisitExplorationScene extends Phaser.Scene { init(data?: CampVisitExplorationSceneData) { this.visitId = - data?.visitId === secondBattleReliefVisitId - ? secondBattleReliefVisitId - : firstPursuitScoutVisitId; + data?.visitId === thirdCampExplorationVisitId + ? thirdCampExplorationVisitId + : data?.visitId === secondBattleReliefVisitId + ? secondBattleReliefVisitId + : firstPursuitScoutVisitId; } preload() { @@ -338,7 +373,10 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.ownedPresentationTextureKeys.add(backgroundKey); this.load.image(backgroundKey, this.currentBackgroundUrl()); } - explorationPortraitEntries(this.currentPortraitContext()).forEach(({ textureKey, url }) => { + explorationPortraitEntries( + this.currentPortraitContext(), + this.currentPortraitKeys() + ).forEach(({ textureKey, url }) => { if (!this.textures.exists(textureKey)) { this.ownedPresentationTextureKeys.add(textureKey); this.load.image(textureKey, url); @@ -348,6 +386,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { create() { this.resetRuntimeState(); + this.visualMotionReduced = isVisualMotionReduced(); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { this.ready = false; this.navigationPending = false; @@ -373,6 +412,21 @@ export class CampVisitExplorationScene extends Phaser.Scene { }); return; } + if (this.isThirdCampVisit()) { + const entry = enterThirdCampExploration(); + if (!entry.ok) { + this.navigationPending = true; + void startGameScene(this, 'CampScene').catch((error) => { + console.error('Failed to leave an unavailable third camp visit.', error); + this.navigationPending = false; + if (this.sys.isActive()) { + this.scene.start('TitleScene'); + } + }); + return; + } + this.thirdCampFirstEntry = entry.changed; + } this.drawCamp(); this.drawHud(); @@ -404,13 +458,28 @@ export class CampVisitExplorationScene extends Phaser.Scene { ambienceVolume: 0.12 } ); - this.showCompletionToast( - this.isSecondReliefVisit() - ? this.reliefGuideMessage() - : '정찰대 막사의 간옹에게 직접 다가가세요.', - 'guide', - 1850 - ); + if (this.isThirdCampVisit() && this.thirdCampFirstEntry) { + const definition = this.thirdCampDefinition(); + this.startDialogue( + definition ? [...definition.introLines] : [], + () => + this.showCompletionToast( + '세 준비는 선택 활동입니다. 필요한 곳만 살피고 언제든 출진할 수 있습니다.', + 'guide', + 2100 + ) + ); + } else { + this.showCompletionToast( + this.isThirdCampVisit() + ? this.thirdCampGuideMessage() + : this.isSecondReliefVisit() + ? this.reliefGuideMessage() + : '정찰대 막사의 간옹에게 직접 다가가세요.', + 'guide', + 1850 + ); + } } update(time: number, delta: number) { @@ -445,9 +514,24 @@ export class CampVisitExplorationScene extends Phaser.Scene { : null; const target = this.nearestInteractionTarget(promptRadius); const completed = this.visitComplete(campaign); - const choiceId = campaign.campVisitChoiceIds[this.visitId] ?? null; + const thirdProgress = this.isThirdCampVisit() + ? thirdCampExplorationProgress(campaign) + : undefined; + const thirdDefinition = this.isThirdCampVisit() + ? resolveThirdCampExplorationDefinition(campaign) + : undefined; + const selectedThirdPriority = thirdProgress?.selectedPriorityId + ? getThirdCampPreparationPriority( + thirdProgress.selectedPriorityId + ) + : undefined; + const choiceId = this.isThirdCampVisit() + ? thirdProgress?.selectedPriorityId ?? null + : campaign.campVisitChoiceIds[this.visitId] ?? null; const chosen: VisitChoice | undefined = choiceId - ? this.isSecondReliefVisit() + ? this.isThirdCampVisit() + ? undefined + : this.isSecondReliefVisit() ? getSecondBattleReliefChoice(choiceId) : getFirstPursuitScoutVisitChoice(choiceId) : undefined; @@ -513,7 +597,8 @@ export class CampVisitExplorationScene extends Phaser.Scene { interact: ['E', 'Space', 'Enter'], choices: ['1', '2', 'pointer'], pointerMove: true, - carryoverGuardMs: inputCarryoverGuardMs + carryoverGuardMs: inputCarryoverGuardMs, + reducedMotion: this.visualMotionReduced }, interaction: { radius: interactionRadius, @@ -544,15 +629,23 @@ export class CampVisitExplorationScene extends Phaser.Scene { textureKey: sprite.texture.key, animationKey: sprite.anims.currentAnim?.key ?? null, objectiveId: definition.objectiveId ?? null, + activityId: definition.activityId ?? null, completed: - definition.kind === 'objective' && - (definition.objectiveId - ? reliefProgress?.completedObjectiveIds.includes( - definition.objectiveId + definition.activityId + ? thirdProgress?.completedActivityIds.includes( + definition.activityId ) ?? false - : completed), + : definition.kind === 'objective' && + (definition.objectiveId + ? reliefProgress?.completedObjectiveIds.includes( + definition.objectiveId + ) ?? false + : completed), current: - definition.objectiveId === reliefProgress?.nextObjectiveId, + definition.activityId + ? definition.activityId === + thirdProgress?.selectedPriorityId + : definition.objectiveId === reliefProgress?.nextObjectiveId, distance: playerPosition ? Phaser.Math.Distance.Between( playerPosition.x, @@ -607,6 +700,8 @@ export class CampVisitExplorationScene extends Phaser.Scene { }, choice: { open: Boolean(this.choicePanel), + mode: this.choicePanelMode, + sourceNpcId: this.choiceSourceNpcId ?? null, ready: Boolean( this.choicePanel && this.time.now >= this.choiceReadyAt ), @@ -624,20 +719,62 @@ export class CampVisitExplorationScene extends Phaser.Scene { }, visit: { id: this.visitId, - mode: this.isSecondReliefVisit() - ? 'second-battle-relief' - : 'first-pursuit-scout', + mode: this.isThirdCampVisit() + ? 'third-guangzong-sortie-camp' + : this.isSecondReliefVisit() + ? 'second-battle-relief' + : 'first-pursuit-scout', completed, choiceId, - choiceLabel: chosen?.label ?? null, + choiceLabel: + selectedThirdPriority?.label ?? chosen?.label ?? null, itemRewards: chosen ? [...chosen.itemRewards] : [], bondExp: chosen?.bondExp ?? 0, completedObjectiveIds: - reliefProgress?.completedObjectiveIds ?? [], + thirdProgress?.completedActivityIds ?? + reliefProgress?.completedObjectiveIds ?? + [], + completedActivityIds: + thirdProgress?.completedActivityIds ?? [], + completedActivityCount: + thirdProgress?.completedActivityCount ?? 0, + activities: + thirdProgress?.activities.map((activity) => ({ + id: activity.id, + label: activity.label, + completed: activity.completed, + selected: activity.selected, + targetActorIds: [...activity.targetActorIds], + worldCue: activity.worldCue + })) ?? [], + selectedPriorityId: + thirdProgress?.selectedPriorityId ?? null, + selectedPriorityLabel: + selectedThirdPriority?.label ?? null, + hudActivityTitle: + this.thirdActivityTitleText?.text ?? null, + hudPriorityLocation: + this.thirdPriorityLocationText?.text ?? null, nextObjectiveId: reliefProgress?.nextObjectiveId ?? null, - choiceReady: reliefProgress?.choiceReady ?? !completed + choiceReady: + thirdProgress?.ready ?? + reliefProgress?.choiceReady ?? + !completed }, - storyContext: reliefContext + storyContext: thirdDefinition + ? { + sourceBattleId: thirdDefinition.sourceBattleId, + targetBattleId: thirdDefinition.targetBattleId, + resultVariant: + thirdDefinition.resultDialogue.variantId, + resultSummary: + thirdDefinition.resultDialogue.summary, + companionDialogueId: + thirdDefinition.companionDialogue.id, + companionBondId: + thirdDefinition.companionDialogue.bondId + } + : reliefContext ? { sourceBattleId: reliefContext.sourceBattleId, sourceBattleTurn: reliefContext.sourceBattleTurn, @@ -648,10 +785,29 @@ export class CampVisitExplorationScene extends Phaser.Scene { scoutReview: { ...reliefContext.scoutReview } } : null, + worldFeedback: this.isThirdCampVisit() + ? thirdDefinition?.activities.map((activity) => { + const view = this.thirdWorldFeedback.get(activity.id); + return { + activityId: activity.id, + cue: activity.worldCue, + visible: view?.visible ?? false, + alpha: view?.alpha ?? 0, + bounds: + view && view.visible + ? this.boundsSnapshot(view.getBounds()) + : null + }; + }) ?? [] + : [], campaign: { latestBattleId: campaign.latestBattleId ?? null, completedCampVisits: [...campaign.completedCampVisits], campVisitChoiceIds: { ...campaign.campVisitChoiceIds }, + thirdCampPreparationSelection: + campaign.thirdCampPreparationSelection + ? { ...campaign.thirdCampPreparationSelection } + : null, inventory: { ...campaign.inventory } }, blockers: this.blockers.map((blocker) => @@ -695,10 +851,19 @@ export class CampVisitExplorationScene extends Phaser.Scene { return true; } + debugAdvanceDialogue() { + if (!this.dialogueState || this.navigationPending) { + return false; + } + this.advanceDialogue(); + return true; + } + debugChooseScout(choiceId: string) { const choice = getFirstPursuitScoutVisitChoice(choiceId); if ( this.isSecondReliefVisit() || + this.isThirdCampVisit() || !choice || this.visitComplete() ) { @@ -727,6 +892,22 @@ export class CampVisitExplorationScene extends Phaser.Scene { } debugChooseVisitChoice(choiceId: string) { + if (this.isThirdCampVisit()) { + const choice = resolveThirdCampCompanionDialogue( + getCampaignState() + )?.choices.find((candidate) => candidate.id === choiceId); + if (!choice) { + return false; + } + if (!this.choicePanel) { + this.openChoicePanel('third-companion'); + this.choiceReadyAt = 0; + } + this.chooseThirdCampCompanion(choice); + return thirdCampExplorationProgress().completedActivityIds.includes( + 'companion' + ); + } if (!this.isSecondReliefVisit()) { return this.debugChooseScout(choiceId); } @@ -742,11 +923,49 @@ export class CampVisitExplorationScene extends Phaser.Scene { return this.visitComplete(); } + debugRecordThirdCampActivity(activityId: string) { + if ( + !this.isThirdCampVisit() || + !['information', 'equipment', 'companion'].includes(activityId) + ) { + return false; + } + this.completeThirdCampActivity( + activityId as ThirdCampExplorationActivityId + ); + return thirdCampExplorationProgress().selectedPriorityId === + activityId; + } + private isSecondReliefVisit() { return this.visitId === secondBattleReliefVisitId; } + private isThirdCampVisit() { + return this.visitId === thirdCampExplorationVisitId; + } + + private thirdCampDefinition() { + return this.isThirdCampVisit() + ? resolveThirdCampExplorationDefinition(getCampaignState()) + : undefined; + } + private currentActorDefinitions() { + if (this.isThirdCampVisit()) { + return (this.thirdCampDefinition()?.actors ?? []).map( + (actor): ExplorationActorDefinition => ({ + ...actor, + dialogue: actor.dialogue.map((line) => ({ ...line })), + repeatDialogue: actor.repeatDialogue.map((line) => ({ + ...line + })), + completedDialogue: actor.completedDialogue.map((line) => ({ + ...line + })) + }) + ); + } return this.isSecondReliefVisit() ? secondReliefActorDefinitions : firstPursuitActorDefinitions; @@ -760,24 +979,58 @@ export class CampVisitExplorationScene extends Phaser.Scene { } private currentPortraitContext() { - return this.isSecondReliefVisit() - ? secondReliefPortraitContext - : firstPursuitPortraitContext; + return this.isThirdCampVisit() + ? thirdCampPortraitContext + : this.isSecondReliefVisit() + ? secondReliefPortraitContext + : firstPursuitPortraitContext; + } + + private currentPortraitKeys() { + if (!this.isThirdCampVisit()) { + return portraitKeys; + } + const definition = this.thirdCampDefinition(); + return [ + 'liuBei', + ...(definition?.introLines.map((line) => line.portraitKey) ?? []), + ...(definition?.actors.flatMap((actor) => [ + actor.portraitKey, + ...actor.dialogue.map((line) => line.portraitKey), + ...actor.repeatDialogue.map((line) => line.portraitKey), + ...actor.completedDialogue.map((line) => line.portraitKey) + ]) ?? []) + ].filter((key): key is string => Boolean(key)); } private currentBackgroundKey() { - return this.isSecondReliefVisit() - ? secondReliefBackgroundKey - : campBackgroundKey; + return this.isThirdCampVisit() + ? thirdCampBackgroundKey + : this.isSecondReliefVisit() + ? secondReliefBackgroundKey + : campBackgroundKey; } private currentBackgroundUrl() { - return this.isSecondReliefVisit() - ? secondReliefBackgroundUrl - : campBackgroundUrl; + return this.isThirdCampVisit() + ? thirdCampBackgroundUrl + : this.isSecondReliefVisit() + ? secondReliefBackgroundUrl + : campBackgroundUrl; } private currentMovementBounds() { + if (this.isThirdCampVisit()) { + const bounds = this.thirdCampDefinition()?.movementBounds; + return bounds + ? new Phaser.Geom.Rectangle( + bounds.x, + bounds.y, + bounds.width, + bounds.height + ) + : movementBounds; + } if (!this.isSecondReliefVisit()) { return movementBounds; } @@ -787,15 +1040,29 @@ export class CampVisitExplorationScene extends Phaser.Scene { } private currentCampExit() { - return this.isSecondReliefVisit() - ? secondBattleReliefExplorationDefinition.exit - : firstPursuitCampExit; + return this.isThirdCampVisit() + ? this.thirdCampDefinition()?.exit ?? firstPursuitCampExit + : this.isSecondReliefVisit() + ? secondBattleReliefExplorationDefinition.exit + : firstPursuitCampExit; } - private currentChoices(): readonly VisitChoice[] { - return this.isSecondReliefVisit() - ? secondBattleReliefExplorationDefinition.choices - : firstPursuitScoutVisitDefinition.choices; + private currentChoices(): readonly ChoiceOption[] { + return this.isThirdCampVisit() + ? resolveThirdCampCompanionDialogue(getCampaignState())?.choices ?? [] + : this.isSecondReliefVisit() + ? secondBattleReliefExplorationDefinition.choices + : firstPursuitScoutVisitDefinition.choices; + } + + private thirdCampGuideMessage() { + const progress = thirdCampExplorationProgress(); + const selected = progress.selectedPriorityId + ? getThirdCampPreparationPriority(progress.selectedPriorityId) + : undefined; + return progress.completedActivityCount > 0 + ? `선택 활동 ${progress.completedActivityCount} / 3 · 현재 우선 ${selected?.label ?? '미정'} · 언제든 출진 가능` + : '준비 활동 0 / 3 · 원하는 곳만 살피고 남쪽 출구에서 언제든 출진할 수 있습니다.'; } private reliefGuideMessage() { @@ -830,7 +1097,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.choicePanel = undefined; this.choicePanelBounds = undefined; this.choiceViews = []; + this.choicePanelMode = 'visit'; + this.choiceSourceNpcId = undefined; this.choiceReadyAt = Number.POSITIVE_INFINITY; + this.thirdWorldFeedback.clear(); + this.thirdCampFirstEntry = false; + this.thirdActivityTitleText = undefined; + this.thirdPriorityLocationText = undefined; this.moveTarget = undefined; this.moveWaypoints = []; this.moveTargetId = undefined; @@ -848,6 +1121,21 @@ export class CampVisitExplorationScene extends Phaser.Scene { private visitAvailable() { const campaign = getCampaignState(); + if (this.isThirdCampVisit()) { + const definition = resolveThirdCampExplorationDefinition(campaign); + const history = definition + ? campaign.battleHistory[definition.sourceBattleId] + : undefined; + return Boolean( + definition && + campaign.step === 'third-camp' && + campaign.latestBattleId === definition.sourceBattleId && + (history?.outcome === 'victory' || + (campaign.firstBattleReport?.battleId === + definition.sourceBattleId && + campaign.firstBattleReport.outcome === 'victory')) + ); + } if (this.isSecondReliefVisit()) { return ( campaign.step === 'second-camp' && @@ -883,6 +1171,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.registerCampCollisions(); this.drawCampWayfinding(); this.drawCampFireGlow(); + if (this.isThirdCampVisit()) { + this.drawThirdCampWorldFeedback(); + } } else { this.backgroundFallback = true; this.add @@ -925,7 +1216,17 @@ export class CampVisitExplorationScene extends Phaser.Scene { } private registerCampCollisions() { - const collisionRects = this.isSecondReliefVisit() + const collisionRects = this.isThirdCampVisit() + ? [ + [42, 108, 1404, 124], + [72, 228, 386, 176], + [548, 222, 282, 174], + [948, 226, 350, 194], + [84, 600, 224, 178], + [1035, 680, 270, 166], + [600, 468, 136, 106] + ] + : this.isSecondReliefVisit() ? [ [42, 108, 230, 340], [42, 570, 300, 245], @@ -955,6 +1256,40 @@ export class CampVisitExplorationScene extends Phaser.Scene { } private drawCampWayfinding() { + if (this.isThirdCampVisit()) { + [ + [360, 318, '동림 선봉 작전판'], + [267, 660, '선봉 보급 수레'], + [1115, 432, '먼저 귀환한 동료'], + [790, 870, '남쪽 · 군영 장부 복귀'] + ].forEach(([x, y, label]) => { + this.add + .text(Number(x), Number(y), String(label), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#f4e3b5', + fontStyle: 'bold', + backgroundColor: '#15120ed4', + padding: { left: 10, right: 10, top: 4, bottom: 4 } + }) + .setOrigin(0.5) + .setDepth(45); + }); + const routeGlow = this.add + .ellipse(776, 655, 610, 390, 0xd8b15f, 0.035) + .setDepth(34); + if (!this.visualMotionReduced) { + this.tweens.add({ + targets: routeGlow, + alpha: { from: 0.02, to: 0.075 }, + duration: 1250, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } + return; + } if (this.isSecondReliefVisit()) { [ [1090, 312, '북쪽 마을'], @@ -978,14 +1313,16 @@ export class CampVisitExplorationScene extends Phaser.Scene { const routeGlow = this.add .ellipse(742, 636, 610, 330, 0xe0bd6e, 0.035) .setDepth(34); - this.tweens.add({ - targets: routeGlow, - alpha: { from: 0.02, to: 0.08 }, - duration: 1100, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut' - }); + if (!this.visualMotionReduced) { + this.tweens.add({ + targets: routeGlow, + alpha: { from: 0.02, to: 0.08 }, + duration: 1100, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } return; } [ @@ -1022,16 +1359,18 @@ export class CampVisitExplorationScene extends Phaser.Scene { const objectiveGlow = this.add .ellipse(510, 492, 150, 86, 0xe0bd6e, 0.08) .setDepth(44); - this.tweens.add({ - targets: objectiveGlow, - alpha: { from: 0.04, to: 0.17 }, - scaleX: { from: 0.9, to: 1.08 }, - scaleY: { from: 0.9, to: 1.08 }, - duration: 820, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut' - }); + if (!this.visualMotionReduced) { + this.tweens.add({ + targets: objectiveGlow, + alpha: { from: 0.04, to: 0.17 }, + scaleX: { from: 0.9, to: 1.08 }, + scaleY: { from: 0.9, to: 1.08 }, + duration: 820, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } } private drawCampFireGlow() { @@ -1039,21 +1378,146 @@ export class CampVisitExplorationScene extends Phaser.Scene { return; } const glow = this.add - .ellipse(780, 540, 230, 150, 0xf2a241, 0.08) + .ellipse( + this.isThirdCampVisit() ? 665 : 780, + this.isThirdCampVisit() ? 530 : 540, + 230, + 150, + 0xf2a241, + 0.08 + ) .setDepth(35); - this.tweens.add({ - targets: glow, - alpha: { from: 0.05, to: 0.14 }, - scaleX: { from: 0.95, to: 1.05 }, - scaleY: { from: 0.95, to: 1.05 }, - duration: 880, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut' + if (!this.visualMotionReduced) { + this.tweens.add({ + targets: glow, + alpha: { from: 0.05, to: 0.14 }, + scaleX: { from: 0.95, to: 1.05 }, + scaleY: { from: 0.95, to: 1.05 }, + duration: 880, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } + } + + private drawThirdCampWorldFeedback() { + const definition = this.thirdCampDefinition(); + if (!definition) { + return; + } + + const routeInk = this.add.graphics(); + routeInk.lineStyle(5, 0xe2bc69, 0.94); + routeInk.beginPath(); + routeInk.moveTo(-78, 20); + routeInk.lineTo(-26, -20); + routeInk.lineTo(24, 6); + routeInk.lineTo(78, -35); + routeInk.strokePath(); + [-78, -26, 24, 78].forEach((x, index) => { + routeInk.fillStyle(index === 3 ? 0xc95742 : 0xf0d995, 0.96); + routeInk.fillCircle(x, index % 2 === 0 ? 20 : -20, 8); + }); + const routeLabel = this.add + .text(0, 55, '적 선봉 · 동림 퇴로 표시', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '15px', + color: '#fff0bd', + fontStyle: 'bold', + backgroundColor: '#271d13e8', + padding: { left: 8, right: 8, top: 4, bottom: 4 } + }) + .setOrigin(0.5); + this.thirdWorldFeedback.set( + 'information', + this.add + .container(330, 390, [routeInk, routeLabel]) + .setDepth(48) + ); + + const cartSeal = this.add + .rectangle(0, 0, 188, 78, 0x3a2718, 0.9) + .setStrokeStyle(3, 0xd4b06a, 0.94); + const cartGuard = this.add + .text(0, -13, '盾 布 盾', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '23px', + color: '#f4ddb0', + fontStyle: 'bold' + }) + .setOrigin(0.5); + const cartLabel = this.add + .text(0, 24, '선봉 방호구 적재 완료', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#dfeccf', + fontStyle: 'bold' + }) + .setOrigin(0.5); + this.thirdWorldFeedback.set( + 'equipment', + this.add + .container(230, 672, [cartSeal, cartGuard, cartLabel]) + .setDepth(49) + ); + + const companionActor = definition.actors.find( + (actor) => actor.activityId === 'companion' + ); + const rallyRing = this.add + .ellipse(0, 24, 154, 66, 0xe0bd6e, 0.08) + .setStrokeStyle(3, 0xe4c778, 0.88); + const rallyMark = this.add + .text(0, -54, '결', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '24px', + color: '#fff1bc', + fontStyle: 'bold', + backgroundColor: '#713b2be8', + padding: { left: 10, right: 10, top: 4, bottom: 4 } + }) + .setOrigin(0.5); + const rallyLabel = this.add + .text(0, 72, '협공 신호 공유', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '15px', + color: '#f4ddb0', + fontStyle: 'bold', + backgroundColor: '#241915df', + padding: { left: 8, right: 8, top: 3, bottom: 3 } + }) + .setOrigin(0.5); + this.thirdWorldFeedback.set( + 'companion', + this.add + .container( + companionActor?.x ?? 1110, + companionActor?.y ?? 520, + [rallyRing, rallyMark, rallyLabel] + ) + .setDepth(95) + ); + this.refreshThirdWorldFeedback(); + } + + private refreshThirdWorldFeedback() { + if (!this.isThirdCampVisit()) { + return; + } + const progress = thirdCampExplorationProgress(); + this.thirdWorldFeedback.forEach((container, activityId) => { + const completed = + progress.completedActivityIds.includes(activityId); + container.setVisible(completed).setAlpha(completed ? 1 : 0); }); } private drawHud() { + if (this.isThirdCampVisit()) { + this.drawThirdCampHud(); + return; + } if (this.isSecondReliefVisit()) { this.drawSecondReliefHud(); return; @@ -1102,7 +1566,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { '필수 준비', '간옹 · 정찰 작전판', '두 추격로 중 먼저 확인할 길을 선택합니다.' - ); + ).background; this.objectiveStatus = this.add .text(1550, 207, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -1208,6 +1672,157 @@ export class CampVisitExplorationScene extends Phaser.Scene { .setVisible(false); } + private drawThirdCampHud() { + const definition = this.thirdCampDefinition(); + if (!definition) { + return; + } + this.add + .text(48, 27, definition.title, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '34px', + color: '#f4e3b5', + fontStyle: 'bold' + }) + .setDepth(1500); + this.add + .text(650, 35, definition.location, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#b4c0cb' + }) + .setDepth(1500); + + this.add + .text(1536, 35, definition.heading, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '27px', + color: '#f4e3b5', + fontStyle: 'bold', + wordWrap: { width: 340, useAdvancedWrap: true } + }) + .setDepth(1500); + this.add + .text( + 1538, + 82, + '모두 선택 활동 · 원하는 순서 · 언제든 군영 장부로 복귀', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#a8d58e', + fontStyle: 'bold', + wordWrap: { width: 330, useAdvancedWrap: true } + } + ) + .setDepth(1500); + + const activityCard = this.drawObjectiveCard( + 150, + '자유 준비 0 / 3', + '작전판 · 보급 수레 · 먼저 귀환한 동료', + '한 곳만 확인해도 방침이 정해지며, 마지막 활동이 현재 우선 기준이 됩니다.' + ); + this.objectiveCard = activityCard.background; + this.thirdActivityTitleText = activityCard.titleText; + this.objectiveStatus = this.add + .text(1550, 166, '◆ 모두 선택', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#e1bc69', + fontStyle: 'bold' + }) + .setDepth(1502); + + const priorityCard = this.drawObjectiveCard( + 342, + '현재 출진 우선', + '아직 정하지 않음', + '완료한 활동을 다시 확인하면 보상 중복 없이 우선 기준만 바뀝니다.' + ); + this.thirdPriorityLocationText = priorityCard.locationText; + + this.drawObjectiveCard( + 534, + '군영 장부로 복귀', + '남쪽 출구 · 항상 가능', + '부분 진행과 선택은 바로 저장됩니다. 준비를 남겨 두고도 돌아갈 수 있습니다.' + ); + this.add + .text(1550, 550, '↩ 언제든 가능', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#a8d58e', + fontStyle: 'bold' + }) + .setDepth(1502); + + this.progressText = this.add + .text(1538, 716, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#cbd3dc', + lineSpacing: 7, + wordWrap: { width: 330, useAdvancedWrap: true } + }) + .setDepth(1502); + + this.add + .text(46, 980, '이동', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }) + .setDepth(1502); + this.add + .text(46, 1012, 'WASD / 방향키 · 바닥이나 인물 클릭', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#e3e7eb' + }) + .setDepth(1502); + this.add + .text(485, 980, '상호작용', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }) + .setDepth(1502); + this.add + .text(485, 1012, '가까이에서 E / Space / Enter', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#e3e7eb' + }) + .setDepth(1502); + this.add + .text(1420, 1012, 'ESC · 대화/선택 닫기', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#9fabb8' + }) + .setOrigin(1, 0) + .setDepth(1502); + + this.promptBackground = this.add + .rectangle(940, 1018, 610, 58, 0x2a2119, 0.98) + .setStrokeStyle(2, 0xd8b15f, 0.92) + .setDepth(1510) + .setVisible(false); + this.promptText = this.add + .text(940, 1018, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '21px', + color: '#f6e3af', + fontStyle: 'bold' + }) + .setOrigin(0.5) + .setDepth(1511) + .setVisible(false); + } + private drawSecondReliefHud() { const definition = secondBattleReliefExplorationDefinition; this.add @@ -1249,7 +1864,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { '순서대로 확인', '관우 → 사공 → 장비 → 간옹', '현장의 증언과 흔적을 하나씩 확인합니다.' - ); + ).background; this.objectiveStatus = this.add .text(1550, 207, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -1366,7 +1981,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { .setOrigin(0) .setStrokeStyle(2, 0x3d4858, 1) .setDepth(1500); - this.add + const titleText = this.add .text(1550, y + 48, title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', @@ -1374,14 +1989,14 @@ export class CampVisitExplorationScene extends Phaser.Scene { fontStyle: 'bold' }) .setDepth(1501); - this.add + const locationText = this.add .text(1550, y + 82, location, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#a4afba' }) .setDepth(1501); - this.add + const helpText = this.add .text(1550, y + 109, help, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', @@ -1389,16 +2004,26 @@ export class CampVisitExplorationScene extends Phaser.Scene { wordWrap: { width: 318, useAdvancedWrap: true } }) .setDepth(1501); - return background; + return { + background, + titleText, + locationText, + helpText + }; } private createActors() { - const start = this.isSecondReliefVisit() - ? secondBattleReliefExplorationDefinition.player - : { x: 780, y: 720 }; - const startingDirection = this.isSecondReliefVisit() - ? secondBattleReliefExplorationDefinition.player.direction - : 'north'; + const thirdDefinition = this.thirdCampDefinition(); + const start = this.isThirdCampVisit() && thirdDefinition + ? thirdDefinition.player + : this.isSecondReliefVisit() + ? secondBattleReliefExplorationDefinition.player + : { x: 780, y: 720 }; + const startingDirection = this.isThirdCampVisit() && thirdDefinition + ? thirdDefinition.player.direction + : this.isSecondReliefVisit() + ? secondBattleReliefExplorationDefinition.player.direction + : 'north'; this.playerDirection = startingDirection; this.playerShadow = this.add .ellipse(start.x, start.y + 20, 66, 25, 0x07100a, 0.45) @@ -1438,7 +2063,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { definition.x, definition.y, definition.id === 'jian-yong' || - definition.id === 'jian-yong-records' + definition.id === 'jian-yong-records' || + definition.id === 'zou-jing-operations' || + definition.id === 'quartermaster-equipment' ? characterDisplaySize + 4 : characterDisplaySize, definition.direction @@ -1458,27 +2085,38 @@ export class CampVisitExplorationScene extends Phaser.Scene { .text( definition.x, definition.y - 77, - definition.kind === 'objective' ? '!' : '…', + definition.kind === 'companion' && !this.isThirdCampVisit() + ? '…' + : '!', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: definition.kind === 'objective' ? '32px' : '23px', + fontSize: + definition.kind === 'companion' && + !this.isThirdCampVisit() + ? '23px' + : '32px', color: '#2a2013', fontStyle: 'bold', backgroundColor: - definition.kind === 'objective' ? '#e5bd68' : '#aeb8c4', + definition.kind === 'companion' && + !this.isThirdCampVisit() + ? '#aeb8c4' + : '#e5bd68', padding: { left: 10, right: 10, top: 3, bottom: 3 } } ) .setOrigin(0.5) .setDepth(1201); - this.tweens.add({ - targets: marker, - y: marker.y - 8, - duration: definition.kind === 'objective' ? 680 : 900, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut' - }); + if (!this.visualMotionReduced) { + this.tweens.add({ + targets: marker, + y: marker.y - 8, + duration: definition.kind === 'objective' ? 680 : 900, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } this.actorViews.set(definition.id, { definition, sprite, @@ -1517,13 +2155,15 @@ export class CampVisitExplorationScene extends Phaser.Scene { ) .setOrigin(0.5) .setDepth(1200); - this.tweens.add({ - targets: exitMarker, - alpha: { from: 0.68, to: 1 }, - duration: 880, - yoyo: true, - repeat: -1 - }); + if (!this.visualMotionReduced) { + this.tweens.add({ + targets: exitMarker, + alpha: { from: 0.68, to: 1 }, + duration: 880, + yoyo: true, + repeat: -1 + }); + } } private createCharacterSprite( @@ -1669,9 +2309,11 @@ export class CampVisitExplorationScene extends Phaser.Scene { return; } this.showPromptMessage( - this.isSecondReliefVisit() - ? '군영으로 돌아가려면 남쪽 귀환 표식까지 직접 이동하세요.' - : '군영 장부로 돌아가려면 남쪽 출구까지 직접 이동하세요.' + this.isThirdCampVisit() + ? '군영 장부로 돌아가려면 남쪽 출구까지 직접 이동하세요. 준비 활동은 모두 선택입니다.' + : this.isSecondReliefVisit() + ? '군영으로 돌아가려면 남쪽 귀환 표식까지 직접 이동하세요.' + : '군영 장부로 돌아가려면 남쪽 출구까지 직접 이동하세요.' ); } @@ -2187,6 +2829,10 @@ export class CampVisitExplorationScene extends Phaser.Scene { return; } this.faceCharactersForConversation(view); + if (this.isThirdCampVisit()) { + this.interactWithThirdCampActor(view); + return; + } if (this.isSecondReliefVisit()) { this.interactWithReliefActor(view); return; @@ -2205,6 +2851,87 @@ export class CampVisitExplorationScene extends Phaser.Scene { ); } + private interactWithThirdCampActor(view: ExplorationActorView) { + const activityId = view.definition.activityId; + if (!activityId) { + this.startDialogue( + [...view.definition.dialogue], + () => this.restoreActorDirection(view), + view.definition.id + ); + return; + } + const progress = thirdCampExplorationProgress(); + const completed = + progress.completedActivityIds.includes(activityId); + + if (activityId === 'companion' && !completed) { + this.startDialogue( + [...view.definition.dialogue], + () => { + this.restoreActorDirection(view); + this.openChoicePanel('third-companion', view.definition.id); + }, + view.definition.id + ); + return; + } + + const lines = completed + ? view.definition.completedDialogue?.length + ? view.definition.completedDialogue + : view.definition.repeatDialogue ?? view.definition.dialogue + : view.definition.dialogue; + this.startDialogue( + [...lines], + () => { + this.restoreActorDirection(view); + this.completeThirdCampActivity(activityId); + }, + view.definition.id + ); + } + + private completeThirdCampActivity( + activityId: ThirdCampExplorationActivityId + ) { + const before = thirdCampExplorationProgress(); + const result = recordThirdCampExplorationActivity(activityId); + if (!result.ok) { + const message = + result.reason === 'choice-required' + ? '동료와 대화를 마치고 응답을 정해야 합니다.' + : result.reason === 'invalid-activity' + ? '이 준비 활동은 기록할 수 없습니다.' + : result.reason === 'invalid-campaign' + ? '현재 군영에서는 이 준비를 진행할 수 없습니다.' + : '준비 기록을 저장하지 못했습니다.'; + this.showCompletionToast(message, 'error'); + return; + } + + const newlyCompleted = + !before.completedActivityIds.includes(activityId); + this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.lastNotice = `${result.activity.shortLabel} · 현재 출진 우선`; + if (newlyCompleted) { + soundDirector.playObjectiveAchieved(); + } else { + soundDirector.playSelect(); + } + this.refreshObjectiveHud(); + this.refreshActorMarkers(); + this.refreshThirdWorldFeedback(); + this.refreshInteractionPrompt(); + this.showCompletionToast( + newlyCompleted + ? result.activity.completionLine + : `${result.priority.label}을 현재 출진 우선으로 다시 선택했습니다. 보상은 중복되지 않습니다.`, + 'success', + newlyCompleted ? 2100 : 1750 + ); + } + private interactWithReliefActor(view: ExplorationActorView) { const objective = view.definition.objectiveId ? getSecondBattleReliefObjective(view.definition.objectiveId) @@ -2522,11 +3249,18 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.refreshInteractionPrompt(); } - private openChoicePanel() { + private openChoicePanel( + mode: ChoicePanelMode = 'visit', + sourceNpcId?: ExplorationActorId + ) { if ( this.choicePanel || this.navigationPending || - this.visitComplete() + (mode === 'visit' && this.visitComplete()) || + (mode === 'third-companion' && + thirdCampExplorationProgress().completedActivityIds.includes( + 'companion' + )) ) { return; } @@ -2552,7 +3286,10 @@ export class CampVisitExplorationScene extends Phaser.Scene { const title = this.add.text( panelBounds.x + 40, panelBounds.y + 38, - this.isSecondReliefVisit() + mode === 'third-companion' + ? resolveThirdCampCompanionDialogue(getCampaignState())?.title ?? + '동료와 다음 호흡 정하기' + : this.isSecondReliefVisit() ? secondBattleReliefExplorationDefinition.title : firstPursuitScoutVisitDefinition.title, { @@ -2565,7 +3302,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { const hint = this.add.text( panelBounds.x + 40, panelBounds.y + 84, - this.isSecondReliefVisit() + mode === 'third-companion' + ? '지난 전투를 함께 되짚고 다음 본영전의 짧은 협공 신호를 정하세요. 숫자 1·2 또는 마우스로 선택할 수 있습니다.' + : this.isSecondReliefVisit() ? '광종 구원로에서 먼저 세울 기준을 선택하세요. 결정과 보상은 저장되며 세 번째 전투의 출진 준비로 이어집니다.' : '먼저 확인할 길을 선택하세요. 결정과 보상은 저장되며 두 번째 전투의 배치 정보로 이어집니다.', { @@ -2653,6 +3392,8 @@ export class CampVisitExplorationScene extends Phaser.Scene { .container(0, 0, objects) .setDepth(3200); this.choicePanelBounds = panelBounds; + this.choicePanelMode = mode; + this.choiceSourceNpcId = sourceNpcId; this.choiceReadyAt = this.time.now + inputCarryoverGuardMs; } @@ -2670,7 +3411,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { } } - private chooseVisitChoice(choice: VisitChoice) { + private chooseVisitChoice(choice: ChoiceOption) { + if (this.isThirdCampVisit()) { + if ('rewardExp' in choice) { + this.chooseThirdCampCompanion(choice); + } + return; + } if (this.isSecondReliefVisit()) { const reliefChoice = getSecondBattleReliefChoice(choice.id); if (reliefChoice) { @@ -2788,20 +3535,103 @@ export class CampVisitExplorationScene extends Phaser.Scene { ); } + private chooseThirdCampCompanion( + choice: ThirdCampCompanionDialogueChoice + ) { + if ( + this.navigationPending || + this.choicePanelMode !== 'third-companion' || + (this.choicePanel && this.time.now < this.choiceReadyAt) + ) { + return; + } + const dialogue = resolveThirdCampCompanionDialogue( + getCampaignState() + ); + const alreadyCompleted = Boolean( + dialogue && + getCampaignState().completedCampDialogues.includes(dialogue.id) + ); + const sourceNpcId = this.choiceSourceNpcId; + const result = completeThirdCampCompanionActivity(choice.id); + if (!result.ok) { + this.closeChoicePanel(); + const message = + result.reason === 'invalid-choice' + ? '선택할 수 없는 응답입니다.' + : result.reason === 'bond-unavailable' + ? '현재 동료 공명 기록을 갱신할 수 없습니다.' + : result.reason === 'invalid-campaign' + ? '현재 군영에서는 이 대화를 진행할 수 없습니다.' + : '동료 대화 기록을 저장하지 못했습니다.'; + this.showCompletionToast(message, 'error'); + this.refreshObjectiveHud(); + this.refreshActorMarkers(); + return; + } + + this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.closeChoicePanel(); + this.lastNotice = '동료 공명 · 현재 출진 우선'; + if (alreadyCompleted) { + soundDirector.playSelect(); + } else { + soundDirector.playEffect('bond-resonance', { + volume: 0.3, + stopAfterMs: 1150 + }); + } + this.refreshObjectiveHud(); + this.refreshActorMarkers(); + this.refreshThirdWorldFeedback(); + this.startDialogue( + [ + { + speaker: '출진 약속', + text: result.choice.response + }, + { + speaker: '공명 기록', + text: alreadyCompleted + ? `${result.memory.label}을 현재 출진 우선으로 다시 선택했습니다. 공명 보상은 중복되지 않습니다.` + : `${result.choice.label} · 공명 +${result.rewardExp} · 다음 본영전 초반 협공 신호에 반영` + } + ], + () => + this.showCompletionToast( + result.activity.completionLine, + 'success', + 2100 + ), + sourceNpcId + ); + } + private closeChoicePanel() { this.choicePanel?.destroy(); this.choicePanel = undefined; this.choicePanelBounds = undefined; this.choiceViews = []; + this.choicePanelMode = 'visit'; + this.choiceSourceNpcId = undefined; this.choiceReadyAt = Number.POSITIVE_INFINITY; this.refreshInteractionPrompt(); } - private choiceRewardText(choice: VisitChoice) { + private choiceRewardText(choice: ChoiceOption) { + if ('rewardExp' in choice) { + const dialogue = resolveThirdCampCompanionDialogue( + getCampaignState() + ); + return `공명 +${(dialogue?.rewardExp ?? 0) + choice.rewardExp} · 보상은 최초 1회`; + } return `공명 +${choice.bondExp} · ${choice.itemRewards.join(' · ')}`; } - private choiceEffectText(choice: VisitChoice) { + private choiceEffectText(choice: ChoiceOption) { + if ('rewardExp' in choice) { + return choice.response; + } if (this.isSecondReliefVisit()) { const reliefChoice = getSecondBattleReliefChoice(choice.id); if (reliefChoice?.thirdBattleEffect.kind === 'village-supply-line') { @@ -2818,6 +3648,28 @@ export class CampVisitExplorationScene extends Phaser.Scene { private interactWithExit() { if (!this.visitComplete()) { + if (this.isThirdCampVisit()) { + const progress = thirdCampExplorationProgress(); + this.startDialogue( + [ + { + speaker: '유비', + portraitKey: 'liuBei', + text: + progress.completedActivityCount > 0 + ? '살핀 준비와 현재 우선 기준은 장부에 남았다. 나머지는 선택이니 군영으로 돌아가 출진 여부를 정하자.' + : '아직 별도 준비를 확인하지 않았지만, 세 활동은 모두 선택이다. 군영 장부로 돌아가 바로 출진해도 된다.' + }, + { + speaker: '광종 출진 기록', + text: `선택 활동 ${progress.completedActivityCount} / 3 · 부분 진행 저장 · 언제든 다시 방문 가능` + } + ], + () => this.returnToCamp(), + 'camp-exit' + ); + return; + } if (this.isSecondReliefVisit()) { const progress = secondBattleReliefProgress(); this.startDialogue( @@ -2895,9 +3747,11 @@ export class CampVisitExplorationScene extends Phaser.Scene { .text( sceneWidth / 2, sceneHeight / 2 - 32, - this.isSecondReliefVisit() - ? '북쪽 마을·나루 수습 기록' - : '탁현 의용군 막사 기록', + this.isThirdCampVisit() + ? '광종 동림 출진 준비 기록' + : this.isSecondReliefVisit() + ? '북쪽 마을·나루 수습 기록' + : '탁현 의용군 막사 기록', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '44px', @@ -2910,13 +3764,17 @@ export class CampVisitExplorationScene extends Phaser.Scene { .text( sceneWidth / 2, sceneHeight / 2 + 44, - this.visitComplete() - ? this.isSecondReliefVisit() - ? '현장 수습과 광종 진군 방침을 출진 장부에 올립니다.' - : '간옹의 정찰 기록을 출진 장부에 올립니다.' - : this.isSecondReliefVisit() - ? '확인한 현장 기록을 저장하고 군영으로 돌아갑니다.' - : '정찰 활동을 남겨 두고 군영 장부로 돌아갑니다.', + this.isThirdCampVisit() + ? this.visitComplete() + ? '선택한 출진 우선과 완료한 활동을 장부에 반영합니다.' + : '선택 활동을 남겨 두고 군영 장부로 돌아갑니다.' + : this.visitComplete() + ? this.isSecondReliefVisit() + ? '현장 수습과 광종 진군 방침을 출진 장부에 올립니다.' + : '간옹의 정찰 기록을 출진 장부에 올립니다.' + : this.isSecondReliefVisit() + ? '확인한 현장 기록을 저장하고 군영으로 돌아갑니다.' + : '정찰 활동을 남겨 두고 군영 장부로 돌아갑니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '24px', @@ -2928,17 +3786,65 @@ export class CampVisitExplorationScene extends Phaser.Scene { .container(0, 0, [shade, title, subtitle]) .setDepth(5000) .setAlpha(0); - this.tweens.add({ - targets: this.transitionOverlay, - alpha: 1, - duration: transitionFadeDurationMs, - ease: 'Sine.easeOut' - }); + if (this.visualMotionReduced) { + this.transitionOverlay.setAlpha(1); + } else { + this.tweens.add({ + targets: this.transitionOverlay, + alpha: 1, + duration: transitionFadeDurationMs, + ease: 'Sine.easeOut' + }); + } } private refreshObjectiveHud() { const campaign = getCampaignState(); const completed = this.visitComplete(campaign); + if (this.isThirdCampVisit()) { + const progress = thirdCampExplorationProgress(campaign); + const selected = progress.selectedPriorityId + ? getThirdCampPreparationPriority( + progress.selectedPriorityId + ) + : undefined; + this.objectiveStatus + ?.setText( + `◆ 선택 활동 ${progress.completedActivityCount} / 3 · 언제든 복귀` + ) + .setColor( + progress.completedActivityCount === 3 + ? '#a8d58e' + : '#e1bc69' + ); + this.objectiveCard?.setStrokeStyle( + 2, + progress.completedActivityCount > 0 ? 0x628653 : 0xd8b15f, + 0.86 + ); + this.thirdActivityTitleText?.setText( + `자유 준비 ${progress.completedActivityCount} / 3` + ); + this.thirdPriorityLocationText?.setText( + selected?.label ?? '아직 정하지 않음' + ); + const activityLines = progress.activities + .map( + (activity) => + `${activity.completed ? '✓' : '!'} ${activity.shortLabel}${ + activity.selected ? ' · 현재 우선' : '' + }` + ) + .join('\n'); + this.progressText?.setText( + `선택 활동 ${progress.completedActivityCount} / 3 · 모두 선택\n` + + `현재 우선 · ${selected?.label ?? '아직 정하지 않음'}\n\n` + + `${activityLines}\n\n` + + '완료한 곳을 다시 확인하면 보상 중복 없이 우선 기준만 바뀝니다.' + ); + this.refreshThirdWorldFeedback(); + return; + } if (this.isSecondReliefVisit()) { const progress = secondBattleReliefProgress(campaign); const choiceId = @@ -3006,10 +3912,33 @@ export class CampVisitExplorationScene extends Phaser.Scene { private refreshActorMarkers() { const completed = this.visitComplete(); + const thirdProgress = this.isThirdCampVisit() + ? thirdCampExplorationProgress() + : undefined; const reliefProgress = this.isSecondReliefVisit() ? secondBattleReliefProgress() : undefined; this.actorViews.forEach(({ definition, marker }) => { + if (this.isThirdCampVisit() && definition.activityId) { + const activityComplete = + thirdProgress?.completedActivityIds.includes( + definition.activityId + ) ?? false; + const selected = + definition.activityId === + thirdProgress?.selectedPriorityId; + marker + .setText(activityComplete ? '✓' : '!') + .setColor(activityComplete ? '#e5f2d5' : '#2a2013') + .setBackgroundColor( + selected + ? '#8a6332' + : activityComplete + ? '#4e7549' + : '#e5bd68' + ); + return; + } if (definition.kind !== 'objective') { marker.setText('…'); return; @@ -3124,17 +4053,24 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.completionToast = this.add .container(0, 0, [background, text]) .setDepth(3300); - this.tweens.add({ - targets: this.completionToast, - alpha: 0, - y: -18, - delay: duration, - duration: 380, - onComplete: () => { + if (this.visualMotionReduced) { + this.time.delayedCall(duration, () => { this.completionToast?.destroy(); this.completionToast = undefined; - } - }); + }); + } else { + this.tweens.add({ + targets: this.completionToast, + alpha: 0, + y: -18, + delay: duration, + duration: 380, + onComplete: () => { + this.completionToast?.destroy(); + this.completionToast = undefined; + } + }); + } } private nearestInteractionTarget(radius: number) { @@ -3161,13 +4097,26 @@ export class CampVisitExplorationScene extends Phaser.Scene { const reliefProgress = this.isSecondReliefVisit() ? secondBattleReliefProgress() : undefined; + const thirdProgress = this.isThirdCampVisit() + ? thirdCampExplorationProgress() + : undefined; return [ ...Array.from(this.actorViews.values()).map( ({ definition, sprite }) => ({ kind: 'actor' as const, id: definition.id, name: - this.isSecondReliefVisit() + this.isThirdCampVisit() + ? definition.activityId + ? `${ + thirdProgress?.completedActivityIds.includes( + definition.activityId + ) + ? '다시 확인 · ' + : '' + }${definition.name} · ${definition.role}` + : `${definition.name}와 대화` + : this.isSecondReliefVisit() ? definition.objectiveId === reliefProgress?.nextObjectiveId ? `${definition.name} · 다음 현장 확인` : `${definition.name}와 대화` @@ -3192,6 +4141,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { } private visitComplete(campaign = getCampaignState()) { + if (this.isThirdCampVisit()) { + return thirdCampExplorationProgress(campaign).ready; + } return campaign.completedCampVisits.includes( this.visitId ); diff --git a/src/game/state/thirdCampExplorationActions.ts b/src/game/state/thirdCampExplorationActions.ts new file mode 100644 index 0000000..feff060 --- /dev/null +++ b/src/game/state/thirdCampExplorationActions.ts @@ -0,0 +1,463 @@ +import { + completedThirdCampExplorationActivityIds, + isThirdCampExplorationActivityId, + resolveThirdCampCompanionDialogue, + resolveThirdCampExplorationDefinition, + thirdCampExplorationActivityProgressId, + thirdCampExplorationSourceBattleId, + thirdCampExplorationVisitId, + type ThirdCampCompanionDialogueChoice, + type ThirdCampCompanionDialogueDefinition, + type ThirdCampExplorationActivityDefinition, + type ThirdCampExplorationActivityId +} from '../data/thirdCampExploration'; +import { + getThirdCampPreparationPriority, + hasThirdCampPreparationSourceVictory, + resolveThirdCampPreparationMemory, + resolveThirdCampPreparationSelection, + thirdCampPreparationSourceBattleId, + thirdCampPreparationTargetBattleId, + type ThirdCampPreparationMemory, + type ThirdCampPreparationPriorityDefinition +} from '../data/thirdCampPreparation'; +import { + applyCampBondExp, + getCampaignState, + setCampaignState, + type CampaignState +} from './campaignState'; + +export type ThirdCampExplorationFailure = + | 'invalid-campaign' + | 'invalid-activity' + | 'choice-required' + | 'invalid-choice' + | 'bond-unavailable' + | 'save-unavailable'; + +export type ThirdCampExplorationEntryResult = + | { + ok: true; + campaign: CampaignState; + changed: boolean; + } + | { + ok: false; + reason: Extract< + ThirdCampExplorationFailure, + 'invalid-campaign' | 'save-unavailable' + >; + }; + +export type ThirdCampExplorationActivityResult = + | { + ok: true; + activity: ThirdCampExplorationActivityDefinition; + priority: ThirdCampPreparationPriorityDefinition; + memory: ThirdCampPreparationMemory; + campaign: CampaignState; + changed: boolean; + } + | { + ok: false; + reason: Extract< + ThirdCampExplorationFailure, + | 'invalid-campaign' + | 'invalid-activity' + | 'choice-required' + | 'save-unavailable' + >; + }; + +export type ThirdCampCompanionActivityResult = + | { + ok: true; + activity: ThirdCampExplorationActivityDefinition; + dialogue: ThirdCampCompanionDialogueDefinition; + choice: ThirdCampCompanionDialogueChoice; + rewardExp: number; + memory: ThirdCampPreparationMemory; + campaign: CampaignState; + changed: boolean; + } + | { + ok: false; + reason: Extract< + ThirdCampExplorationFailure, + | 'invalid-campaign' + | 'invalid-choice' + | 'bond-unavailable' + | 'save-unavailable' + >; + }; + +export function thirdCampExplorationProgress( + campaign: CampaignState = getCampaignState() +) { + const definition = + resolveThirdCampExplorationDefinition(campaign); + const completedActivityIds = + completedThirdCampExplorationActivityIds( + campaign.completedCampVisits + ); + const completed = new Set(completedActivityIds); + const selection = resolveThirdCampPreparationSelection(campaign); + + return { + visitId: thirdCampExplorationVisitId, + entered: campaign.completedCampVisits.includes( + thirdCampExplorationVisitId + ), + completedActivityIds, + completedActivityCount: completedActivityIds.length, + activities: + definition?.activities.map((activity) => ({ + ...activity, + targetActorIds: [...activity.targetActorIds], + completed: completed.has(activity.id), + selected: selection?.priorityId === activity.id + })) ?? [], + selectedPriorityId: selection?.priorityId ?? null, + ready: selection?.selectable === true + }; +} + +export function enterThirdCampExploration(): + ThirdCampExplorationEntryResult { + const snapshot = readCampaignSnapshot(); + if (!snapshot) { + return { ok: false, reason: 'save-unavailable' }; + } + if (!campaignSupportsThirdCampExploration(snapshot)) { + return { ok: false, reason: 'invalid-campaign' }; + } + + const markerIds = [thirdCampExplorationVisitId]; + const companionDialogue = + resolveThirdCampCompanionDialogue(snapshot); + if ( + companionDialogue && + snapshot.completedCampDialogues.includes(companionDialogue.id) + ) { + markerIds.push( + thirdCampExplorationActivityProgressId('companion') + ); + } + const updated = withVisitMarkers(snapshot, markerIds); + if ( + updated.completedCampVisits.length === + snapshot.completedCampVisits.length + ) { + return { ok: true, campaign: snapshot, changed: false }; + } + + const persisted = persistCampaign(updated, snapshot); + return persisted + ? { ok: true, campaign: persisted, changed: true } + : { ok: false, reason: 'save-unavailable' }; +} + +export function recordThirdCampExplorationActivity( + priorityId: string +): ThirdCampExplorationActivityResult { + const snapshot = readCampaignSnapshot(); + if (!snapshot) { + return { ok: false, reason: 'save-unavailable' }; + } + if (!campaignSupportsThirdCampExploration(snapshot)) { + return { ok: false, reason: 'invalid-campaign' }; + } + if (!isThirdCampExplorationActivityId(priorityId)) { + return { ok: false, reason: 'invalid-activity' }; + } + + const definition = + resolveThirdCampExplorationDefinition(snapshot); + const activity = definition?.activities.find( + (candidate) => candidate.id === priorityId + ); + const priority = getThirdCampPreparationPriority(priorityId); + if (!activity || !priority) { + return { ok: false, reason: 'invalid-activity' }; + } + if ( + priorityId === 'companion' && + !hasCompletedCompanionActivity(snapshot) + ) { + return { ok: false, reason: 'choice-required' }; + } + + const persisted = persistActivitySelection( + snapshot, + priorityId + ); + if (!persisted) { + return { ok: false, reason: 'save-unavailable' }; + } + const memory = selectedMemory(persisted, priorityId); + if (!memory) { + restoreCampaignSnapshot(snapshot); + return { ok: false, reason: 'save-unavailable' }; + } + + return { + ok: true, + activity, + priority, + memory, + campaign: persisted, + changed: + !snapshot.completedCampVisits.includes( + thirdCampExplorationActivityProgressId(priorityId) + ) || + snapshot.thirdCampPreparationSelection?.priorityId !== priorityId + }; +} + +export function completeThirdCampCompanionActivity( + choiceId: string +): ThirdCampCompanionActivityResult { + const snapshot = readCampaignSnapshot(); + if (!snapshot) { + return { ok: false, reason: 'save-unavailable' }; + } + if (!campaignSupportsThirdCampExploration(snapshot)) { + return { ok: false, reason: 'invalid-campaign' }; + } + + const definition = + resolveThirdCampExplorationDefinition(snapshot); + const activity = definition?.activities.find( + (candidate) => candidate.id === 'companion' + ); + const dialogue = resolveThirdCampCompanionDialogue(snapshot); + const choice = dialogue?.choices.find( + (candidate) => candidate.id === choiceId + ); + if (!activity || !dialogue || !choice) { + return { ok: false, reason: 'invalid-choice' }; + } + + const dialogueAlreadyCompleted = + snapshot.completedCampDialogues.includes(dialogue.id); + if (!dialogueAlreadyCompleted) { + const report = applyCompanionBondReward( + snapshot, + dialogue, + choice + ); + if (!report) { + return { ok: false, reason: 'bond-unavailable' }; + } + } + + const rewardExp = dialogue.rewardExp + choice.rewardExp; + const afterDialogue = readCampaignSnapshot(); + if (!afterDialogue) { + restoreCampaignSnapshot(snapshot); + return { ok: false, reason: 'save-unavailable' }; + } + const persisted = persistActivitySelection( + afterDialogue, + 'companion', + snapshot + ); + if (!persisted) { + return { ok: false, reason: 'save-unavailable' }; + } + const memory = selectedMemory(persisted, 'companion'); + if (!memory) { + restoreCampaignSnapshot(snapshot); + return { ok: false, reason: 'save-unavailable' }; + } + + return { + ok: true, + activity, + dialogue, + choice, + rewardExp, + memory, + campaign: persisted, + changed: + !dialogueAlreadyCompleted || + !snapshot.completedCampVisits.includes( + thirdCampExplorationActivityProgressId('companion') + ) || + snapshot.thirdCampPreparationSelection?.priorityId !== + 'companion' + }; +} + +function campaignSupportsThirdCampExploration( + campaign: CampaignState +) { + const settlement = + campaign.battleHistory[thirdCampExplorationSourceBattleId]; + const report = campaign.firstBattleReport; + return ( + campaign.step === 'third-camp' && + campaign.latestBattleId === + thirdCampExplorationSourceBattleId && + settlement?.battleId === + thirdCampExplorationSourceBattleId && + settlement.outcome === 'victory' && + report?.battleId === thirdCampExplorationSourceBattleId && + report.outcome === 'victory' && + hasThirdCampPreparationSourceVictory(campaign) && + Boolean(resolveThirdCampExplorationDefinition(campaign)) + ); +} + +function hasCompletedCompanionActivity(campaign: CampaignState) { + if ( + campaign.completedCampVisits.includes( + thirdCampExplorationActivityProgressId('companion') + ) + ) { + return true; + } + const dialogue = resolveThirdCampCompanionDialogue(campaign); + return Boolean( + dialogue && + campaign.completedCampDialogues.includes(dialogue.id) + ); +} + +function persistActivitySelection( + current: CampaignState, + activityId: ThirdCampExplorationActivityId, + rollbackSnapshot: CampaignState = current +) { + const activityMarker = + thirdCampExplorationActivityProgressId(activityId); + let updated = withVisitMarkers(current, [ + thirdCampExplorationVisitId, + activityMarker + ]); + updated = { + ...updated, + acknowledgedVictoryRewardCategories: + acknowledgedRewardCategoryForActivity(updated, activityId), + thirdCampPreparationSelection: { + sourceBattleId: thirdCampPreparationSourceBattleId, + targetBattleId: thirdCampPreparationTargetBattleId, + priorityId: activityId + } + }; + return persistCampaign(updated, rollbackSnapshot); +} + +function acknowledgedRewardCategoryForActivity( + campaign: CampaignState, + activityId: ThirdCampExplorationActivityId +) { + const category: 'unlocks' | 'equipment' | undefined = + activityId === 'information' + ? 'unlocks' + : activityId === 'equipment' + ? 'equipment' + : undefined; + if (!category) { + return campaign.acknowledgedVictoryRewardCategories; + } + const previous = + campaign.acknowledgedVictoryRewardCategories[ + thirdCampExplorationSourceBattleId + ] ?? []; + return { + ...campaign.acknowledgedVictoryRewardCategories, + [thirdCampExplorationSourceBattleId]: Array.from( + new Set([...previous, category]) + ) + }; +} + +function withVisitMarkers( + campaign: CampaignState, + markerIds: readonly string[] +): CampaignState { + const completedCampVisits = Array.from( + new Set([...campaign.completedCampVisits, ...markerIds]) + ); + return { + ...campaign, + completedCampVisits, + ...(campaign.firstBattleReport + ? { + firstBattleReport: { + ...campaign.firstBattleReport, + completedCampVisits: Array.from( + new Set([ + ...campaign.firstBattleReport.completedCampVisits, + ...markerIds + ]) + ) + } + } + : {}) + }; +} + +function selectedMemory( + campaign: CampaignState, + activityId: ThirdCampExplorationActivityId +) { + const memory = resolveThirdCampPreparationMemory({ + campaign, + battleId: thirdCampPreparationTargetBattleId + }); + return memory?.priorityId === activityId ? memory : undefined; +} + +function applyCompanionBondReward( + snapshot: CampaignState, + dialogue: ThirdCampCompanionDialogueDefinition, + choice: ThirdCampCompanionDialogueChoice +) { + try { + const rewardExp = dialogue.rewardExp + choice.rewardExp; + const report = applyCampBondExp( + dialogue.id, + dialogue.bondId, + rewardExp, + choice.id + ); + if (!report) { + restoreCampaignSnapshot(snapshot); + } + return report; + } catch { + restoreCampaignSnapshot(snapshot); + return undefined; + } +} + +function readCampaignSnapshot() { + try { + return getCampaignState(); + } catch { + return undefined; + } +} + +function persistCampaign( + updated: CampaignState, + rollbackSnapshot: CampaignState +) { + try { + return setCampaignState(updated); + } catch { + restoreCampaignSnapshot(rollbackSnapshot); + return undefined; + } +} + +function restoreCampaignSnapshot(snapshot: CampaignState) { + try { + setCampaignState(snapshot); + } catch { + // setCampaignState restores the in-memory snapshot before persistence. + } +}