From adf9e65668a78d193c46a865ad9ed8c9884dd27b Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 27 Jul 2026 13:09:59 +0900 Subject: [PATCH] feat: carry third-camp preparation into battle --- docs/game-design-improvement-goal.md | 25 +- package.json | 4 +- scripts/verify-battle-save-normalization.mjs | 196 ++ scripts/verify-static-data.mjs | 1 + .../verify-third-camp-preparation-browser.mjs | 1656 +++++++++++++++++ scripts/verify-third-camp-preparation.mjs | 963 ++++++++++ src/game/data/thirdCampPreparation.ts | 639 +++++++ src/game/scenes/BattleScene.ts | 949 +++++++++- src/game/scenes/CampScene.ts | 486 ++++- src/game/state/battleSaveState.ts | 162 ++ src/game/state/campaignState.ts | 59 + src/game/state/thirdCampPreparationActions.ts | 210 +++ 12 files changed, 5307 insertions(+), 43 deletions(-) create mode 100644 scripts/verify-third-camp-preparation-browser.mjs create mode 100644 scripts/verify-third-camp-preparation.mjs create mode 100644 src/game/data/thirdCampPreparation.ts create mode 100644 src/game/state/thirdCampPreparationActions.ts diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index 30fdbb6..0ade48f 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -187,9 +187,28 @@ RPG로 다듬는다. 다시 군영에 들어와도 완료 상태를 유지한다. - 네 가지 목표 조합과 Canvas·WebGL의 1920x1080 진행을 자동 검증한다. -다음 초반 묶음은 세 번째 군영에서 정보 수집, 장비 정비, 동료 대화 가운데 -이번 출진의 준비 우선순위 하나를 직접 정하고, 그 선택이 네 번째 전투 -브리핑과 첫 3턴 안에 짧고 명확하게 되돌아오게 한다. +### 완료한 9차 묶음: 세 번째 군영의 준비 우선순위와 네 번째 전투 회수 + +- 세 번째 승리 뒤 군영에서 `본영 정보`, `장비 정비`, `동료 공명` 가운데 + 이번 출진의 준비 우선순위 하나를 직접 정한다. +- 승전 보고의 정보·장비 범주를 실제로 확인했거나 결과별 우선 귀환 대화를 + 완료한 항목만 선택할 수 있어, 보지 않은 활동을 완료한 것처럼 취급하지 + 않는다. +- 본영 정보는 배치 때 표식 선봉의 첫 행동을 보여 주고 첫 3턴 안 해당 + 대상 첫 공격의 명중을 한 번 `+10%p`, 장비 정비는 같은 기간 아군의 첫 + 피격 피해를 한 번 `-8%` 보정한다. +- 동료 공명은 우선 귀환 대화에 참여한 정확한 공명조에만 첫 3턴 협공 + 확률 `+5%p`를 적용하며, 다른 조합에는 옮겨 붙지 않는다. +- 선택부터 네 번째 전투의 배치·턴·결과까지 같은 `selectionRecordId`와 + 항목명을 사용하고, 결과에서 실제 발동 여부와 횟수를 되돌려 보여 준다. +- 전투 저장에는 발동 소비 여부, 공명 시도·성공 횟수와 막은 피해를 함께 + 보존해 재개해도 효과가 되살아나거나 중복 적용되지 않게 한다. +- 전투 장면을 연속으로 재사용해도 이전 전투의 턴과 진영이 남지 않도록 + 매 전투를 `1턴 · 아군`에서 초기화한다. + +다음 묶음은 서주 체류에서 장비를 구매한 뒤 실제 무장에게 장착하고, 그 +선택을 여덟 번째 전투의 출진 전 비교·전투 수치·결과 피드백까지 같은 +장비 기록으로 이어지게 한다. ## 장기 개선 순서 diff --git a/package.json b/package.json index 94d4c15..cea4a80 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "verify:second-battle-relief:browser": "node scripts/verify-second-battle-relief-exploration-browser.mjs", "verify:third-battle-return": "node scripts/verify-third-battle-return-dialogue.mjs", "verify:third-battle-return:browser": "node scripts/verify-third-battle-priority-return-browser.mjs", + "verify:third-camp-preparation": "node scripts/verify-third-camp-preparation.mjs", + "verify:third-camp-preparation:browser": "node scripts/verify-third-camp-preparation-browser.mjs", "verify:prologue-exploration-assets": "node scripts/verify-prologue-exploration-asset-data.mjs", "verify:exploration-backgrounds": "node scripts/verify-prologue-exploration-asset-data.mjs", "verify:exploration-characters": "node scripts/verify-exploration-character-asset-data.mjs", @@ -75,7 +77,7 @@ "verify:interaction-ux": "node scripts/verify-interaction-ux.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", - "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", + "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", "verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl", "verify:public-deploy": "node scripts/verify-public-deploy.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs", diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index 1edcc29..4c003e4 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -10,6 +10,12 @@ try { const { isValidBattleSaveState, normalizeBattleSaveSlot, normalizeBattleSaveState, parseBattleSaveState } = await server.ssrLoadModule( '/src/game/state/battleSaveState.ts' ); + const { + getThirdCampPreparationPriority, + thirdCampPreparationInformationEnemyUnitId, + thirdCampPreparationSelectionRecordId, + thirdCampPreparationTargetBattleId + } = await server.ssrLoadModule('/src/game/data/thirdCampPreparation.ts'); const options = { expectedBattleId: 'first-battle-zhuo-commandery', @@ -210,6 +216,196 @@ try { }, options), 'Expected direct validation to reject unsafe core resonance totals before normalization.' ); + const fourthBattleOptions = { + ...options, + expectedBattleId: thirdCampPreparationTargetBattleId + }; + const fourthBattleBaseState = { + ...validState, + battleId: thirdCampPreparationTargetBattleId, + campaignStep: 'fourth-battle', + enemyUsableUseKeys: [] + }; + const thirdPreparationLabels = Object.fromEntries( + ['information', 'equipment', 'companion'].map((priorityId) => [ + priorityId, + getThirdCampPreparationPriority(priorityId)?.label + ]) + ); + const validThirdPreparationStates = [ + { + selectionRecordId: thirdCampPreparationSelectionRecordId, + priorityId: 'information', + label: thirdPreparationLabels.information, + markedEnemyUnitId: thirdCampPreparationInformationEnemyUnitId, + informationConsumed: true, + equipmentConsumed: false, + companionAttempts: 0, + companionSuccesses: 0, + usageCount: 1, + preventedDamage: 0 + }, + { + selectionRecordId: thirdCampPreparationSelectionRecordId, + priorityId: 'equipment', + label: thirdPreparationLabels.equipment, + informationConsumed: false, + equipmentConsumed: true, + companionAttempts: 0, + companionSuccesses: 0, + usageCount: 1, + preventedDamage: 8 + }, + { + selectionRecordId: thirdCampPreparationSelectionRecordId, + priorityId: 'companion', + label: thirdPreparationLabels.companion, + informationConsumed: false, + equipmentConsumed: false, + companionAttempts: 3, + companionSuccesses: 2, + usageCount: 3, + preventedDamage: 0 + } + ]; + validThirdPreparationStates.forEach((thirdCampPreparation) => { + const state = { ...fourthBattleBaseState, thirdCampPreparation }; + const parsed = parseBattleSaveState(JSON.stringify(state), fourthBattleOptions); + assert( + isValidBattleSaveState(state, fourthBattleOptions) && + JSON.stringify(parsed?.thirdCampPreparation) === JSON.stringify(thirdCampPreparation) && + parsed?.thirdCampPreparation !== thirdCampPreparation, + `Expected ${thirdCampPreparation.priorityId} preparation progress to round-trip as a deep-cloned save field.` + ); + }); + + const normalizedWrongBattlePreparation = parseBattleSaveState(JSON.stringify({ + ...validState, + thirdCampPreparation: validThirdPreparationStates[0] + }), options); + assert( + normalizedWrongBattlePreparation && + normalizedWrongBattlePreparation.thirdCampPreparation === undefined && + !isValidBattleSaveState( + { ...validState, thirdCampPreparation: validThirdPreparationStates[0] }, + options + ), + 'Expected preparation progress attached to a different battle to be discarded and rejected by direct validation.' + ); + const normalizedUnknownPreparationPriority = parseBattleSaveState(JSON.stringify({ + ...fourthBattleBaseState, + thirdCampPreparation: { + ...validThirdPreparationStates[0], + priorityId: 'unknown-priority' + } + }), fourthBattleOptions); + const normalizedWrongPreparationRecord = parseBattleSaveState(JSON.stringify({ + ...fourthBattleBaseState, + thirdCampPreparation: { + ...validThirdPreparationStates[0], + selectionRecordId: 'forged-preparation-record' + } + }), fourthBattleOptions); + assert( + normalizedUnknownPreparationPriority?.thirdCampPreparation === undefined && + normalizedWrongPreparationRecord?.thirdCampPreparation === undefined, + 'Expected unknown priorities and forged preparation record ids to be removed without rejecting the battle save.' + ); + + const normalizedInformationPreparation = parseBattleSaveState(JSON.stringify({ + ...fourthBattleBaseState, + thirdCampPreparation: { + ...validThirdPreparationStates[0], + label: 'forged-label', + markedEnemyUnitId: 'forged-vanguard', + informationConsumed: false, + equipmentConsumed: true, + companionAttempts: 7, + companionSuccesses: 6, + usageCount: 999, + preventedDamage: 90 + } + }), fourthBattleOptions)?.thirdCampPreparation; + assert( + normalizedInformationPreparation?.label === thirdPreparationLabels.information && + normalizedInformationPreparation.markedEnemyUnitId === thirdCampPreparationInformationEnemyUnitId && + normalizedInformationPreparation.informationConsumed === false && + normalizedInformationPreparation.equipmentConsumed === false && + normalizedInformationPreparation.companionAttempts === 0 && + normalizedInformationPreparation.companionSuccesses === 0 && + normalizedInformationPreparation.usageCount === 0 && + normalizedInformationPreparation.preventedDamage === 0, + `Expected information preparation to restore the canonical mark and derive its unconsumed state: ${JSON.stringify(normalizedInformationPreparation)}` + ); + + const normalizedUnusedEquipmentPreparation = parseBattleSaveState(JSON.stringify({ + ...fourthBattleBaseState, + thirdCampPreparation: { + ...validThirdPreparationStates[1], + markedEnemyUnitId: thirdCampPreparationInformationEnemyUnitId, + equipmentConsumed: false, + usageCount: 55, + preventedDamage: 55 + } + }), fourthBattleOptions)?.thirdCampPreparation; + const normalizedConsumedEquipmentPreparation = parseBattleSaveState(JSON.stringify({ + ...fourthBattleBaseState, + thirdCampPreparation: { + ...validThirdPreparationStates[1], + markedEnemyUnitId: thirdCampPreparationInformationEnemyUnitId, + usageCount: 0, + preventedDamage: Number.MAX_SAFE_INTEGER + } + }), fourthBattleOptions)?.thirdCampPreparation; + assert( + normalizedUnusedEquipmentPreparation?.markedEnemyUnitId === undefined && + normalizedUnusedEquipmentPreparation?.equipmentConsumed === false && + normalizedUnusedEquipmentPreparation?.usageCount === 0 && + normalizedUnusedEquipmentPreparation?.preventedDamage === 0 && + normalizedConsumedEquipmentPreparation?.markedEnemyUnitId === undefined && + normalizedConsumedEquipmentPreparation?.equipmentConsumed === true && + normalizedConsumedEquipmentPreparation?.usageCount === 1 && + normalizedConsumedEquipmentPreparation?.preventedDamage === 999999, + 'Expected equipment preparation to remove unrelated marks, derive consumption count, and clamp prevented damage.' + ); + + const normalizedOversizedCompanionPreparation = parseBattleSaveState(JSON.stringify({ + ...fourthBattleBaseState, + thirdCampPreparation: { + ...validThirdPreparationStates[2], + markedEnemyUnitId: thirdCampPreparationInformationEnemyUnitId, + informationConsumed: true, + equipmentConsumed: true, + companionAttempts: Number.MAX_SAFE_INTEGER, + companionSuccesses: Number.MAX_SAFE_INTEGER, + usageCount: 1, + preventedDamage: 1234 + } + }), fourthBattleOptions)?.thirdCampPreparation; + assert( + normalizedOversizedCompanionPreparation?.markedEnemyUnitId === undefined && + normalizedOversizedCompanionPreparation?.informationConsumed === false && + normalizedOversizedCompanionPreparation?.equipmentConsumed === false && + normalizedOversizedCompanionPreparation?.companionAttempts === 999999 && + normalizedOversizedCompanionPreparation?.companionSuccesses === 999999 && + normalizedOversizedCompanionPreparation?.usageCount === 999999 && + normalizedOversizedCompanionPreparation?.preventedDamage === 0, + `Expected companion preparation counters to clamp and unrelated consumption fields to clear: ${JSON.stringify(normalizedOversizedCompanionPreparation)}` + ); + + const { thirdCampPreparation: _legacyPreparation, ...legacyFourthBattleState } = { + ...fourthBattleBaseState, + thirdCampPreparation: validThirdPreparationStates[0] + }; + const parsedLegacyFourthBattleState = parseBattleSaveState( + JSON.stringify(legacyFourthBattleState), + fourthBattleOptions + ); + assert( + isValidBattleSaveState(legacyFourthBattleState, fourthBattleOptions) && + parsedLegacyFourthBattleState?.thirdCampPreparation === undefined, + 'Expected legacy fourth-battle saves without preparation progress to remain valid.' + ); const validSortieStatsState = { ...validState, battleStats: { diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 58ea3b7..0fef1ae 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -28,6 +28,7 @@ const checks = [ 'scripts/verify-first-pursuit-scout-memory.mjs', 'scripts/verify-second-battle-relief-exploration.mjs', 'scripts/verify-third-battle-return-dialogue.mjs', + 'scripts/verify-third-camp-preparation.mjs', 'scripts/verify-prologue-exploration-asset-data.mjs', 'scripts/verify-exploration-character-asset-data.mjs', 'scripts/verify-prologue-dialogue-portrait-data.mjs', diff --git a/scripts/verify-third-camp-preparation-browser.mjs b/scripts/verify-third-camp-preparation-browser.mjs new file mode 100644 index 0000000..9949390 --- /dev/null +++ b/scripts/verify-third-camp-preparation-browser.mjs @@ -0,0 +1,1656 @@ +import assert from 'node:assert/strict'; +import { mkdirSync } from 'node:fs'; +import { chromium } from 'playwright'; +import { createServer } from 'vite'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; + +const sourceBattleId = 'third-battle-guangzong-road'; +const targetBattleId = 'fourth-battle-guangzong-camp'; +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 activeThroughTurn = 3; + +const priorityFixtures = [ + { + id: 'information', + label: '본영 정보', + effectKind: 'marked-vanguard-intel', + configuredValue: 10 + }, + { + id: 'equipment', + label: '장비 정비', + effectKind: 'prepared-equipment-guard', + configuredValue: 8 + }, + { + id: 'companion', + label: '동료 공명', + effectKind: 'return-bond-opening', + configuredValue: 5 + } +]; + +const rendererFixtures = { + canvas: { + renderer: 'canvas', + expectedRendererType: 1 + }, + webgl: { + renderer: 'webgl', + expectedRendererType: 2 + } +}; + +const requestedRenderer = + cliOption('renderer') ?? + process.env.VERIFY_THIRD_CAMP_PREPARATION_RENDERER ?? + 'both'; +assert( + ['canvas', 'webgl', 'both'].includes(requestedRenderer), + `Unknown renderer "${requestedRenderer}". Use canvas, webgl, or both.` +); +const renderers = + requestedRenderer === 'both' + ? ['canvas', 'webgl'] + : [requestedRenderer]; + +const server = await createServer({ + logLevel: 'error', + server: { host: '127.0.0.1', port: 0, hmr: false }, + appType: 'spa' +}); +let browser; + +try { + mkdirSync('dist', { recursive: true }); + await server.listen(); + const address = server.httpServer?.address(); + assert( + address && typeof address !== 'string', + 'Expected a local Vite verification server.' + ); + const baseUrl = `http://127.0.0.1:${address.port}/heros_web/`; + browser = await chromium.launch({ + headless: + process.env.VERIFY_THIRD_CAMP_PREPARATION_HEADLESS !== '0' + }); + + for (const renderer of renderers) { + await verifyRenderer( + browser, + baseUrl, + rendererFixtures[renderer] + ); + } + + console.log( + `Third-camp preparation browser verification passed for ${renderers.join( + ' + ' + )} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, DPR ${desktopBrowserDeviceScaleFactor}: camp activity routing and persistence, all three battle mechanics, normalized runtime saves, result memory, and turn-4 expiry.` + ); +} finally { + await browser?.close(); + await server.close(); +} + +async function verifyRenderer(browser, baseUrl, rendererFixture) { + const context = await browser.newContext(desktopBrowserContextOptions); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + const pageErrors = []; + const consoleErrors = []; + page.on('pageerror', (error) => + pageErrors.push(error.stack ?? error.message) + ); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + try { + const url = new URL(baseUrl); + url.searchParams.set('debug', '1'); + url.searchParams.set('renderer', rendererFixture.renderer); + await page.goto(url.toString(), { + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await assertFhdViewport(page, rendererFixture); + + if (rendererFixture.renderer === 'webgl') { + await verifyCampPreparationUi(page); + await assertFhdViewport(page, rendererFixture); + } + + for (const priority of priorityFixtures) { + await verifyBattlePriority( + page, + rendererFixture, + priority + ); + } + + assert.deepEqual( + pageErrors, + [], + `${rendererFixture.renderer}: expected no browser page errors: ${JSON.stringify( + pageErrors.slice(-8) + )}` + ); + assert.deepEqual( + consoleErrors.filter( + (message) => + !message.includes( + 'The AudioContext was not allowed to start' + ) + ), + [], + `${rendererFixture.renderer}: expected no browser console errors: ${JSON.stringify( + consoleErrors.slice(-8) + )}` + ); + } finally { + await context.close(); + } +} + +async function verifyCampPreparationUi(page) { + const seeded = await seedThirdVictory(page, { + acknowledgeActivities: false, + selectPriorityId: null + }); + assert.equal(seeded.step, 'third-camp'); + assert.equal(seeded.latestBattleId, sourceBattleId); + assert.equal(seeded.selection, null); + assert.deepEqual(seeded.acknowledgedCategories, []); + assert.equal(seeded.priorityDialogueCompleted, false); + + await page.evaluate(async () => { + await window.__HEROS_DEBUG__.goToCamp(); + }); + await waitForCamp(page); + await page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + if (typeof scene?.showSortiePrep !== 'function') { + throw new Error( + 'CampScene.showSortiePrep is unavailable.' + ); + } + scene.showSortiePrep(); + }); + 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 + ); + assertFiniteBounds( + camp.thirdCampPreparation.toggleButtonBounds, + 'camp preparation toggle' + ); + assertBoundsInsideViewport( + camp.thirdCampPreparation.toggleButtonBounds, + 'camp preparation toggle' + ); + + camp = await openPreparationBrowser(page); + assertPreparationBrowserLayout(camp, 'initial browser'); + await capture( + page, + 'dist/verification-third-camp-preparation-webgl-browser.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); + await clickSceneBounds( + 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'); + assert.equal( + preparationPriority(camp, 'equipment').evidence?.category, + 'equipment' + ); + 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' + ); + + 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.' + ); + const dialogueChoice = camp.selectedDialogue.choices[0]; + assertFiniteBounds( + dialogueChoice.bounds, + `dialogue choice ${dialogueChoice.id}` + ); + assertBoundsInsideViewport( + dialogueChoice.bounds, + `dialogue choice ${dialogueChoice.id}` + ); + await clickSceneBounds( + page, + 'CampScene', + dialogueChoice.bounds + ); + 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 } + ); + + 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' + ); + + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await page.evaluate(async () => { + await window.__HEROS_DEBUG__.goToCamp(); + }); + await waitForCamp(page); + camp = await openPreparationBrowser(page); + assertPreparationBrowserLayout(camp, 're-entered browser'); + assertPreparationSelection(camp, 'companion'); + await assertCampaignSelectionPersisted( + page, + 'companion', + 're-entered companion selection' + ); + assert.equal( + camp.campaign.completedCampVisits.includes( + selectionRecordId + ), + false, + 'A mutable preparation selection must not masquerade as a completed camp visit.' + ); +} + +async function verifyBattlePriority( + page, + rendererFixture, + priority +) { + const fixtureLabel = `${rendererFixture.renderer}-${priority.id}`; + const seeded = await seedThirdVictory(page, { + acknowledgeActivities: true, + selectPriorityId: priority.id + }); + assert.deepEqual(seeded.selection, { + sourceBattleId, + targetBattleId, + priorityId: priority.id + }); + assert.equal(seeded.memory?.selectionRecordId, selectionRecordId); + assert.equal(seeded.memory?.priorityId, priority.id); + assert.equal(seeded.memory?.label, priority.label); + + await page.evaluate(async (battleId) => { + await window.__HEROS_DEBUG__.goToBattle(battleId); + }, targetBattleId); + let battle = await waitForBattlePreparation( + page, + priority.id + ); + await assertFhdViewport(page, rendererFixture); + assertBattleMemory( + battle.thirdCampPreparation, + priority, + 'deployment' + ); + assert.equal(battle.phase, 'deployment'); + assert.equal( + battle.turnNumber, + 1, + `${fixtureLabel}: each reused BattleScene must initialize at turn 1.` + ); + assert.equal( + battle.activeFaction, + 'ally', + `${fixtureLabel}: each reused BattleScene must initialize with the allied faction.` + ); + assert.equal( + battle.thirdCampPreparation.presentation.deployment.status, + 'deployment' + ); + assert.equal( + battle.thirdCampPreparation.presentation.deployment + .selectionRecordId, + selectionRecordId + ); + assert.equal( + battle.thirdCampPreparation.presentation.turn + .selectionRecordId, + selectionRecordId + ); + assert.equal( + battle.thirdCampPreparation.presentation.turn.status, + 'active' + ); + assert.equal( + battle.thirdCampPreparation.chip.visible, + false, + 'The turn chip must stay out of the deployment overlay.' + ); + if (priority.id === 'information') { + assert.equal( + battle.thirdCampPreparation.trackedEnemyUnitId, + trackedEnemyUnitId + ); + assert.equal( + battle.thirdCampPreparation.deploymentIntent.enabled, + true + ); + assert.equal( + battle.thirdCampPreparation.deploymentIntent.visible, + true + ); + assert.equal( + battle.thirdCampPreparation.deploymentIntent.enemyId, + trackedEnemyUnitId + ); + assert( + battle.thirdCampPreparation.deploymentIntent.forecast, + `${fixtureLabel}: the marked vanguard forecast must be visible during deployment.` + ); + } else { + assert.equal( + battle.thirdCampPreparation.deploymentIntent.enabled, + false + ); + } + + assertMechanicsProbe( + battle.thirdCampPreparation.mechanicsProbe, + priority, + `${fixtureLabel} deployment` + ); + await capture( + page, + `dist/verification-third-camp-preparation-${fixtureLabel}-deployment.png` + ); + + const consumed = await consumePreparationAndRoundTripSave( + page, + priority.id + ); + assertMechanicsProbe( + consumed.before.mechanicsProbe, + priority, + `${fixtureLabel} turn` + ); + assert.equal(consumed.before.chip.visible, true); + assertBoundsInsideViewport( + consumed.before.chip.bounds, + `${fixtureLabel} active chip` + ); + assertRuntimeConsumed( + consumed.after.runtime, + priority, + consumed.preventedDamage + ); + assertRuntimeConsumed( + consumed.normalized, + priority, + consumed.preventedDamage + ); + assert.deepEqual( + consumed.restored.runtime, + consumed.after.runtime, + `${fixtureLabel}: normalized save progress must restore exactly.` + ); + assert.deepEqual( + consumed.restored.saveSnapshot, + consumed.normalized, + `${fixtureLabel}: the restored debug save snapshot must match the normalized field.` + ); + assert.equal( + consumed.normalized.selectionRecordId, + selectionRecordId + ); + assert.equal( + consumed.normalized.priorityId, + priority.id + ); + assert.equal(consumed.normalized.label, priority.label); + assert.equal( + consumed.normalized.markedEnemyUnitId, + priority.id === 'information' + ? trackedEnemyUnitId + : undefined + ); + assert.equal(consumed.deepCloned, true); + assert.equal(consumed.after.chip.visible, true); + assertBoundsInsideViewport( + consumed.after.chip.bounds, + `${fixtureLabel} consumed chip` + ); + + const expired = await expirePreparationAtTurnFour(page); + assertBattleMemory(expired, priority, 'turn 4'); + assert.equal(expired.effectActive, false); + assert.equal(expired.presentation.turn.status, 'expired'); + assert.equal( + expired.presentation.turn.selectionRecordId, + selectionRecordId + ); + assert.equal(expired.presentation.turn.turnNumber, 4); + assert.equal(expired.chip.visible, true); + assert.match( + expired.chip.text, + /사용 완료|종료/, + 'Turn 4 must show either the already-consumed state or an unused preparation expiry.' + ); + assertBoundsInsideViewport( + expired.chip.bounds, + `${fixtureLabel} expired chip` + ); + assertExpiredMechanicsProbe( + expired.mechanicsProbe, + priority, + fixtureLabel + ); + assertRuntimeConsumed( + expired.runtime, + priority, + consumed.preventedDamage + ); + + await page.evaluate(() => { + window.__HEROS_DEBUG__.forceBattleOutcome('victory'); + }); + await page.waitForFunction( + () => { + const state = window.__HEROS_DEBUG__?.battle?.(); + return ( + state?.battleOutcome === 'victory' && + state?.resultVisible === true && + state?.thirdCampPreparation?.presentation?.result + ?.selectionRecordId === + 'third-camp-preparation-priority' + ); + }, + undefined, + { timeout: 90000 } + ); + battle = await readBattle(page); + assertBattleMemory( + battle.thirdCampPreparation, + priority, + 'result' + ); + assert.equal( + battle.thirdCampPreparation.presentation.result.status, + 'applied' + ); + assert.equal( + battle.thirdCampPreparation.presentation.result + .selectionRecordId, + selectionRecordId + ); + assert.equal( + battle.thirdCampPreparation.presentation.result.usageCount, + 1 + ); + const resultTextProbe = await page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('BattleScene'); + return (scene?.resultObjects ?? []) + .filter( + (object) => + object?.type === 'Text' && + object.active && + object.visible && + typeof object.text === 'string' + ) + .map((object) => object.text); + }); + assert( + resultTextProbe.some((text) => + text.includes( + battle.thirdCampPreparation.presentation.result.text + ) + ), + `${fixtureLabel}: the visible result subtitle must include the applied camp preparation memory: ${JSON.stringify( + resultTextProbe + )}` + ); + await capture( + page, + `dist/verification-third-camp-preparation-${fixtureLabel}-result.png` + ); +} + +async function seedThirdVictory( + page, + { acknowledgeActivities, selectPriorityId } +) { + return page.evaluate( + async ({ + sourceBattleId, + targetBattleId, + priorityDialogueId, + priorityDialogueBondId, + acknowledgeActivities, + selectPriorityId + }) => { + const game = window.__HEROS_GAME__; + for (const scene of game?.scene.getScenes(true) ?? []) { + game.scene.stop(scene.scene.key); + } + window.localStorage.clear(); + + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const actionModule = await import( + '/heros_web/src/game/state/thirdCampPreparationActions.ts' + ); + const preparationModule = await import( + '/heros_web/src/game/data/thirdCampPreparation.ts' + ); + const { battleScenarios } = await import( + '/heros_web/src/game/data/battles.ts' + ); + const scenario = battleScenarios[sourceBattleId]; + if (!scenario) { + throw new Error( + `Missing source scenario ${sourceBattleId}.` + ); + } + + campaignModule.resetCampaignState(); + campaignModule.setFirstBattleReport({ + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: 22, + rewardGold: scenario.baseVictoryGold, + defeatedEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + totalEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + objectives: scenario.objectives.map((objective) => ({ + id: objective.id, + label: objective.label, + achieved: true, + status: 'done', + detail: '달성', + category: + objective.id === 'fort' || + objective.id === 'quick' + ? 'bonus' + : 'required', + rewardGold: objective.rewardGold + })), + units: scenario.units, + bonds: scenario.bonds.map((bond) => ({ + ...bond, + battleExp: 0 + })), + itemRewards: [...scenario.itemRewards], + campaignRewards: { + supplies: [ + ...(scenario.campaignReward?.supplies ?? []) + ], + equipment: [ + ...(scenario.campaignReward?.equipment ?? []) + ], + reputation: [ + ...(scenario.campaignReward?.reputation ?? []) + ], + recruits: [], + unlocks: scenario.campaignReward?.unlockBattleId + ? [ + { + battleId: + scenario.campaignReward.unlockBattleId, + title: + scenario.campaignReward.unlockLabel ?? + scenario.campaignReward.unlockBattleId + } + ] + : [], + note: scenario.campaignReward?.note + }, + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-27T00:00:00.000Z' + }); + let campaign = campaignModule.getCampaignState(); + campaign.dismissedVictoryRewardNoticeBattleIds = [ + ...new Set([ + ...campaign.dismissedVictoryRewardNoticeBattleIds, + sourceBattleId + ]) + ]; + campaign.selectedSortieUnitIds = [ + 'liu-bei', + 'guan-yu', + 'zhang-fei' + ]; + campaignModule.setCampaignState(campaign); + 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 + ) + ) { + throw new Error( + 'Failed to complete the priority return dialogue seed.' + ); + } + } + + let selectionResult = null; + if (selectPriorityId) { + selectionResult = + actionModule.setThirdCampPreparationPriority( + selectPriorityId + ); + if (!selectionResult.ok) { + throw new Error( + `Failed to select ${selectPriorityId}: ${selectionResult.reason}` + ); + } + } + + campaign = campaignModule.loadCampaignState(); + const memory = + preparationModule.resolveThirdCampPreparationMemory({ + campaign, + battleId: targetBattleId + }); + return { + step: campaign.step, + latestBattleId: campaign.latestBattleId ?? null, + selection: + campaign.thirdCampPreparationSelection ?? null, + acknowledgedCategories: + campaign.acknowledgedVictoryRewardCategories?.[ + sourceBattleId + ] ?? [], + priorityDialogueCompleted: + campaign.completedCampDialogues.includes( + priorityDialogueId + ), + memory: memory + ? { + sourceBattleId: memory.sourceBattleId, + targetBattleId: memory.targetBattleId, + selectionRecordId: memory.selectionRecordId, + priorityId: memory.priorityId, + label: memory.label, + effectKind: memory.effect.kind + } + : null, + selectionChanged: + selectionResult?.ok === true + ? selectionResult.changed + : null + }; + }, + { + sourceBattleId, + targetBattleId, + priorityDialogueId, + priorityDialogueBondId, + acknowledgeActivities, + selectPriorityId + } + ); +} + +async function consumePreparationAndRoundTripSave( + page, + priorityId +) { + return page.evaluate(async (priorityId) => { + const scene = + window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if ( + !scene || + typeof scene.renderTacticalInitiativeChip !== + 'function' || + typeof scene.createBattleSaveState !== 'function' + ) { + throw new Error( + 'BattleScene preparation debug hooks are unavailable.' + ); + } + + scene.phase = 'idle'; + scene.renderTacticalInitiativeChip(); + const before = + window.__HEROS_DEBUG__.battle().thirdCampPreparation; + const probe = before.mechanicsProbe; + let preventedDamage = 0; + + if (priorityId === 'information') { + const attacker = scene.debugUnitById( + probe.attackerId + ); + const defender = scene.debugUnitById( + probe.defenderId + ); + const preview = scene.combatPreview( + attacker, + defender, + 'attack' + ); + scene.recordThirdCampPreparationCombatUse( + preview, + false, + 0 + ); + } else if (priorityId === 'equipment') { + const attacker = scene.debugUnitById( + probe.attackerId + ); + const defender = scene.debugUnitById( + probe.defenderId + ); + const preview = scene.combatPreview( + attacker, + defender, + 'attack' + ); + preventedDamage = probe.preventedDamage; + scene.recordThirdCampPreparationCombatUse( + preview, + true, + preventedDamage + ); + } else { + scene.recordThirdCampPreparationCompanionAttempt({ + bondId: probe.bondId, + succeeded: true + }); + } + + const after = + window.__HEROS_DEBUG__.battle().thirdCampPreparation; + const fullState = scene.createBattleSaveState(); + const saveModule = await import( + '/heros_web/src/game/state/battleSaveState.ts' + ); + const normalized = saveModule.normalizeBattleSaveState( + fullState, + { + expectedBattleId: fullState.battleId, + allowTacticalCommand: true + } + ); + if (!normalized?.thirdCampPreparation) { + throw new Error( + `Normalized ${priorityId} preparation progress is missing.` + ); + } + const deepCloned = + normalized !== fullState && + normalized.thirdCampPreparation !== + fullState.thirdCampPreparation; + + scene.resetThirdCampPreparationRuntime(); + scene.restoreThirdCampPreparationRuntime( + normalized.thirdCampPreparation + ); + scene.renderTacticalInitiativeChip(); + const restored = + window.__HEROS_DEBUG__.battle().thirdCampPreparation; + return { + before, + after, + normalized: normalized.thirdCampPreparation, + restored, + preventedDamage, + deepCloned + }; + }, priorityId); +} + +async function expirePreparationAtTurnFour(page) { + return page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('BattleScene'); + scene.turnNumber = 4; + scene.phase = 'idle'; + scene.renderTacticalInitiativeChip(); + return window.__HEROS_DEBUG__.battle() + .thirdCampPreparation; + }); +} + +function assertBattleMemory(memory, priority, stage) { + assert( + memory?.active, + `${priority.id} ${stage}: preparation memory is missing.` + ); + assert.equal(memory.sourceBattleId, sourceBattleId); + assert.equal(memory.targetBattleId, targetBattleId); + assert.equal(memory.selectionRecordId, selectionRecordId); + assert.equal(memory.priorityId, priority.id); + assert.equal(memory.label, priority.label); + assert.equal(memory.effectKind, priority.effectKind); + assert.equal(memory.activeThroughTurn, activeThroughTurn); +} + +function assertMechanicsProbe(probe, priority, label) { + assert(probe, `${label}: mechanicsProbe is missing.`); + assert.equal(probe.kind, priority.effectKind); + if (priority.id === 'information') { + assert.equal(probe.defenderId, trackedEnemyUnitId); + assert.equal( + probe.configuredHitBonus, + priority.configuredValue + ); + assert.equal( + probe.expectedHitBonus, + priority.configuredValue + ); + assert.equal( + probe.hitRate, + Math.min( + 98, + probe.baselineHitRate + priority.configuredValue + ), + `${label}: +10%p must be applied before the global 98% hit cap.` + ); + assert.equal( + probe.appliedHitBonus, + probe.hitRate - probe.baselineHitRate + ); + return; + } + if (priority.id === 'equipment') { + assert.equal( + probe.configuredReductionPercent, + priority.configuredValue + ); + assert.equal( + probe.expectedReductionPercent, + priority.configuredValue + ); + const expectedDamage = + probe.baselineDamage > 1 + ? Math.min( + probe.baselineDamage - 1, + Math.max( + 1, + Math.round( + probe.baselineDamage * + (1 - priority.configuredValue / 100) + ) + ) + ) + : probe.baselineDamage; + assert.equal(probe.damage, expectedDamage); + assert.equal( + probe.preventedDamage, + probe.baselineDamage - probe.damage + ); + assert( + probe.preventedDamage > 0, + `${label}: the one-time 8% guard must prevent at least one point for the deterministic probe.` + ); + return; + } + assert.equal(probe.bondId, priorityDialogueBondId); + assert.equal(probe.bondAvailable, true); + assert.equal( + probe.preparationBonus, + priority.configuredValue + ); + assert.equal( + probe.expectedPreparationBonus, + priority.configuredValue + ); + assert.equal( + probe.effectiveRate, + Math.min( + 100, + probe.baseRate + + probe.memoryBonus + + priority.configuredValue + ) + ); +} + +function assertExpiredMechanicsProbe( + probe, + priority, + label +) { + assert(probe, `${label}: turn-4 mechanicsProbe is missing.`); + if (priority.id === 'information') { + assert.equal(probe.configuredHitBonus, 0); + assert.equal(probe.appliedHitBonus, 0); + assert.equal(probe.hitRate, probe.baselineHitRate); + return; + } + if (priority.id === 'equipment') { + assert.equal(probe.configuredReductionPercent, 0); + assert.equal(probe.preventedDamage, 0); + assert.equal(probe.damage, probe.baselineDamage); + return; + } + assert.equal(probe.preparationBonus, 0); + assert.equal( + probe.effectiveRate, + Math.min(100, probe.baseRate + probe.memoryBonus) + ); +} + +function assertRuntimeConsumed( + runtime, + priority, + preventedDamage +) { + assert(runtime, `${priority.id}: runtime snapshot is missing.`); + assert.equal( + runtime.informationConsumed, + priority.id === 'information' + ); + assert.equal( + runtime.equipmentConsumed, + priority.id === 'equipment' + ); + assert.equal( + runtime.companionAttempts, + priority.id === 'companion' ? 1 : 0 + ); + assert.equal( + runtime.companionSuccesses, + priority.id === 'companion' ? 1 : 0 + ); + assert.equal(runtime.usageCount, 1); + assert.equal( + runtime.preventedDamage, + priority.id === 'equipment' ? preventedDamage : 0 + ); + if ( + Object.prototype.hasOwnProperty.call( + runtime, + 'markedEnemyUnitId' + ) && + priority.id === 'information' + ) { + assert.equal( + runtime.markedEnemyUnitId, + trackedEnemyUnitId + ); + } else if ( + Object.prototype.hasOwnProperty.call( + runtime, + 'markedEnemyUnitId' + ) + ) { + assert.equal(runtime.markedEnemyUnitId, undefined); + } +} + +async function waitForDebugApi(page) { + await page.waitForFunction( + () => + document.querySelector('canvas') !== null && + window.__HEROS_GAME__ !== undefined && + window.__HEROS_DEBUG__ !== undefined, + undefined, + { timeout: 90000 } + ); +} + +async function waitForCamp(page) { + await page.waitForFunction( + () => { + const state = window.__HEROS_DEBUG__?.camp?.(); + return ( + window.__HEROS_DEBUG__ + ?.activeScenes?.() + .includes('CampScene') && + state?.scene === 'CampScene' && + state?.campaign?.step === 'third-camp' && + state?.campBattleId === + 'third-battle-guangzong-road' + ); + }, + undefined, + { timeout: 90000 } + ); + return readCamp(page); +} + +async function waitForPreparationToggle(page) { + await page.waitForFunction( + () => { + const state = window.__HEROS_DEBUG__?.camp?.(); + return ( + state?.sortieVisible === true && + state?.sortiePrepStep === 'briefing' && + state?.thirdCampPreparation?.available === true && + state.thirdCampPreparation.browserOpen === false && + Boolean( + state.thirdCampPreparation.toggleButtonBounds + ) + ); + }, + undefined, + { timeout: 30000 } + ); + return readCamp(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) + ) + ); + }, + undefined, + { timeout: 30000 } + ); + return readCamp(page); +} + +async function selectPreparationCard(page, priorityId) { + const camp = await readCamp(page); + const priority = preparationPriority(camp, priorityId); + assert.equal( + priority.selectable, + true, + `${priorityId}: expected the card to be selectable.` + ); + await clickSceneBounds( + page, + 'CampScene', + priority.actionButtonBounds + ); +} + +async function waitForCampSelection(page, priorityId) { + await page.waitForFunction( + ({ priorityId, selectionRecordId }) => { + const preparation = + window.__HEROS_DEBUG__?.camp?.() + ?.thirdCampPreparation; + return ( + preparation?.selectionRecordId === + selectionRecordId && + preparation?.ready === true && + preparation?.selectedPriorityId === priorityId && + preparation?.browserOpen === true && + preparation?.priorities?.find( + ({ id }) => id === priorityId + )?.selected === true + ); + }, + { priorityId, selectionRecordId }, + { timeout: 30000 } + ); + return readCamp(page); +} + +async function waitForBattlePreparation(page, priorityId) { + try { + await page.waitForFunction( + ({ targetBattleId, priorityId, selectionRecordId }) => { + const state = window.__HEROS_DEBUG__?.battle?.(); + const preparation = state?.thirdCampPreparation; + return ( + state?.scene === 'BattleScene' && + state?.battleId === targetBattleId && + state?.phase === 'deployment' && + ['ready', 'degraded'].includes( + state?.combatAssets?.status + ) && + preparation?.active === true && + preparation?.effectActive === true && + preparation?.selectionRecordId === + selectionRecordId && + preparation?.priorityId === priorityId && + preparation?.presentation?.deployment + ?.selectionRecordId === selectionRecordId && + preparation?.presentation?.turn + ?.selectionRecordId === selectionRecordId && + Boolean(preparation?.mechanicsProbe) && + (priorityId !== 'information' || + preparation?.deploymentIntent?.visible === true) + ); + }, + { targetBattleId, priorityId, selectionRecordId }, + { timeout: 90000 } + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + battle: window.__HEROS_DEBUG__?.battle?.() ?? null + })); + throw new Error( + `BattleScene did not expose ${priorityId} preparation during deployment: ${JSON.stringify( + diagnostic + )}`, + { cause: error } + ); + } + return readBattle(page); +} + +function assertPreparationBrowserLayout(camp, label) { + const preparation = camp.thirdCampPreparation; + assert.equal(preparation.available, true); + assert.equal(preparation.browserOpen, true); + assert.equal(preparation.selectionRecordId, selectionRecordId); + assertFiniteBounds( + preparation.closeButtonBounds, + `${label} close button` + ); + assertBoundsInsideViewport( + preparation.closeButtonBounds, + `${label} close button` + ); + assert.equal(preparation.priorities.length, 3); + + const cards = preparation.priorities.map((priority) => { + assertFiniteBounds( + priority.cardBounds, + `${label} ${priority.id} card` + ); + assertFiniteBounds( + priority.actionButtonBounds, + `${label} ${priority.id} action` + ); + assertBoundsInsideViewport( + priority.cardBounds, + `${label} ${priority.id} card` + ); + assertBoundsInsideViewport( + priority.actionButtonBounds, + `${label} ${priority.id} action` + ); + assertBoundsContains( + priority.cardBounds, + priority.actionButtonBounds, + `${label} ${priority.id} action inside card` + ); + return { + id: priority.id, + bounds: priority.cardBounds + }; + }); + assertNoOverlappingBounds(cards, `${label} cards`); + assertNoOverlappingBounds( + preparation.priorities.map((priority) => ({ + id: priority.id, + bounds: priority.actionButtonBounds + })), + `${label} action buttons` + ); + cards.forEach((card) => { + assert( + !boundsIntersect( + card.bounds, + preparation.closeButtonBounds + ), + `${label}: ${card.id} card must not overlap the close button.` + ); + }); +} + +function assertPreparationSelection(camp, priorityId) { + const preparation = camp.thirdCampPreparation; + assert.equal(preparation.selectionRecordId, selectionRecordId); + assert.equal(preparation.ready, true); + assert.equal( + preparation.selectedPriorityId, + priorityId + ); + assert.equal( + preparation.priorities.filter( + ({ selected }) => selected + ).length, + 1 + ); + assert.equal( + preparationPriority(camp, priorityId).selected, + true + ); +} + +async function assertCampaignSelectionPersisted( + page, + priorityId, + label +) { + const persisted = await page.evaluate(() => { + const raw = window.localStorage.getItem( + 'heros-web:campaign-state' + ); + return raw ? JSON.parse(raw) : null; + }); + assert(persisted, `${label}: campaign save is missing.`); + assert.deepEqual( + persisted.thirdCampPreparationSelection, + { + sourceBattleId, + targetBattleId, + priorityId + } + ); + assert.equal( + persisted.completedCampVisits.includes( + selectionRecordId + ), + false + ); + assert.equal( + persisted.firstBattleReport?.completedCampVisits?.includes( + selectionRecordId + ), + false + ); +} + +function preparationPriority(camp, priorityId) { + const priority = + camp?.thirdCampPreparation?.priorities?.find( + ({ id }) => id === priorityId + ); + assert( + priority, + `CampScene is missing the ${priorityId} preparation card: ${JSON.stringify( + camp?.thirdCampPreparation + )}` + ); + return priority; +} + +async function clickSceneBounds( + page, + sceneKey, + bounds +) { + assertFiniteBounds(bounds, `${sceneKey} click target`); + const point = await page.evaluate( + ({ sceneKey, bounds }) => { + const scene = + window.__HEROS_GAME__?.scene.getScene(sceneKey); + const canvas = document.querySelector('canvas'); + const canvasBounds = canvas?.getBoundingClientRect(); + if (!scene || !canvasBounds) { + return null; + } + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + x: + canvasBounds.left + + (centerX * canvasBounds.width) / + scene.scale.width, + y: + canvasBounds.top + + (centerY * canvasBounds.height) / + scene.scale.height + }; + }, + { sceneKey, bounds } + ); + assert( + point && + Number.isFinite(point.x) && + Number.isFinite(point.y), + `Unable to map ${sceneKey} bounds to a browser point: ${JSON.stringify( + bounds + )}` + ); + await page.mouse.click(point.x, point.y); +} + +async function readCamp(page) { + return page.evaluate(() => + window.__HEROS_DEBUG__?.camp?.() + ); +} + +async function readBattle(page) { + return page.evaluate(() => + window.__HEROS_DEBUG__?.battle?.() + ); +} + +function assertNoOverlappingBounds(entries, label) { + for ( + let leftIndex = 0; + leftIndex < entries.length; + leftIndex += 1 + ) { + for ( + let rightIndex = leftIndex + 1; + rightIndex < entries.length; + rightIndex += 1 + ) { + const left = entries[leftIndex]; + const right = entries[rightIndex]; + assert( + !boundsIntersect(left.bounds, right.bounds), + `${label}: ${left.id} and ${right.id} overlap: ${JSON.stringify( + { left, right } + )}` + ); + } + } +} + +function boundsIntersect(left, right) { + return Boolean( + left && + right && + left.x < right.x + right.width && + left.x + left.width > right.x && + left.y < right.y + right.height && + left.y + left.height > right.y + ); +} + +function assertBoundsContains(outer, inner, label) { + const epsilon = 0.01; + assert( + inner.x >= outer.x - epsilon && + inner.y >= outer.y - epsilon && + inner.x + inner.width <= + outer.x + outer.width + epsilon && + inner.y + inner.height <= + outer.y + outer.height + epsilon, + `${label}: ${JSON.stringify({ outer, inner })}` + ); +} + +function assertBoundsInsideViewport(bounds, label) { + assertFiniteBounds(bounds, label); + const epsilon = 0.01; + assert( + bounds.x >= -epsilon && + bounds.y >= -epsilon && + bounds.x + bounds.width <= + desktopBrowserViewport.width + epsilon && + bounds.y + bounds.height <= + desktopBrowserViewport.height + epsilon, + `${label}: expected bounds inside the 1920x1080 viewport, received ${JSON.stringify( + bounds + )}` + ); +} + +function assertFiniteBounds(bounds, label) { + assert(bounds, `${label}: bounds are required.`); + assert( + ['x', 'y', 'width', 'height'].every((key) => + Number.isFinite(bounds[key]) + ) && + bounds.width > 0 && + bounds.height > 0, + `${label}: expected positive finite bounds, received ${JSON.stringify( + bounds + )}` + ); +} + +async function assertFhdViewport(page, fixture) { + const viewport = await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + return { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio, + visualScale: window.visualViewport?.scale ?? 1, + rendererType: + window.__HEROS_GAME__?.renderer?.type ?? null, + canvas: canvas + ? { + width: canvas.width, + height: canvas.height, + clientWidth: canvas.clientWidth, + clientHeight: canvas.clientHeight, + bounds: bounds + ? { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + } + : null + } + : null + }; + }); + assert.equal(viewport.width, desktopBrowserViewport.width); + assert.equal( + viewport.height, + desktopBrowserViewport.height + ); + assert.equal( + viewport.dpr, + desktopBrowserDeviceScaleFactor + ); + assert.equal(viewport.visualScale, 1); + assert.equal( + viewport.rendererType, + fixture.expectedRendererType, + `${fixture.renderer}: Phaser did not use the requested renderer.` + ); + assert.equal( + viewport.canvas?.width, + desktopBrowserViewport.width + ); + assert.equal( + viewport.canvas?.height, + desktopBrowserViewport.height + ); + assert.equal( + viewport.canvas?.clientWidth, + desktopBrowserViewport.width + ); + assert.equal( + viewport.canvas?.clientHeight, + desktopBrowserViewport.height + ); + assert.equal( + viewport.canvas?.bounds?.width, + desktopBrowserViewport.width + ); + assert.equal( + viewport.canvas?.bounds?.height, + desktopBrowserViewport.height + ); +} + +async function capture(page, path) { + await page.waitForTimeout(180); + const loopSlept = await page.evaluate(() => { + const loop = window.__HEROS_GAME__?.loop; + if (!loop || typeof loop.sleep !== 'function') { + return false; + } + loop.sleep(); + return true; + }); + await page.waitForTimeout(40); + try { + await page.screenshot({ path, fullPage: true }); + } finally { + if (loopSlept) { + await page.evaluate(() => + window.__HEROS_GAME__?.loop.wake() + ); + await page.waitForTimeout(40); + } + } +} + +function cliOption(name) { + const equalsPrefix = `--${name}=`; + const equalsOption = process.argv.find((argument) => + argument.startsWith(equalsPrefix) + ); + if (equalsOption) { + return equalsOption.slice(equalsPrefix.length); + } + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : undefined; +} diff --git a/scripts/verify-third-camp-preparation.mjs b/scripts/verify-third-camp-preparation.mjs new file mode 100644 index 0000000..58da91e --- /dev/null +++ b/scripts/verify-third-camp-preparation.mjs @@ -0,0 +1,963 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'vite'; + +const storage = new Map(); +let rejectStorageWrites = false; +globalThis.window = { + localStorage: { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + if (rejectStorageWrites) { + throw new Error('Simulated campaign storage write failure.'); + } + storage.set(key, String(value)); + }, + removeItem(key) { + storage.delete(key); + }, + clear() { + storage.clear(); + } + } +}; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const dataModule = await server.ssrLoadModule( + '/src/game/data/thirdCampPreparation.ts' + ); + const actionModule = await server.ssrLoadModule( + '/src/game/state/thirdCampPreparationActions.ts' + ); + const campaignModule = await server.ssrLoadModule( + '/src/game/state/campaignState.ts' + ); + const { battleScenarios } = await server.ssrLoadModule( + '/src/game/data/battles.ts' + ); + + verifyCanonicalDefinitions(dataModule, battleScenarios); + verifyActivityGates(dataModule); + verifyTargetBattleMemoryIsolation(dataModule); + verifyBattleLifecycleContract(dataModule); + verifyGuardedPersistentOverwrite( + dataModule, + actionModule, + campaignModule, + battleScenarios + ); + verifyOldSaveAndPollutionSafety( + dataModule, + actionModule, + campaignModule, + battleScenarios + ); + + console.log( + 'Third-camp preparation verification passed (persistent activity gates, guarded overwrite, fourth-battle isolation, three-turn lifecycle, applied/unused evaluation, and old-save pollution safety).' + ); +} finally { + await server.close(); +} + +function verifyCanonicalDefinitions(dataModule, battleScenarios) { + const source = + battleScenarios[dataModule.thirdCampPreparationSourceBattleId]; + const target = + battleScenarios[dataModule.thirdCampPreparationTargetBattleId]; + assert.equal(source?.id, 'third-battle-guangzong-road'); + assert.equal(target?.id, 'fourth-battle-guangzong-camp'); + assert.equal( + dataModule.thirdCampPreparationSelectionRecordId, + 'third-camp-preparation-priority' + ); + assert.equal(dataModule.thirdCampPreparationActiveThroughTurn, 3); + assert.deepEqual( + dataModule.thirdCampPreparationPriorityIds, + ['information', 'equipment', 'companion'] + ); + + const definitions = dataModule.thirdCampPreparationPriorities; + assert.equal(definitions.length, 3); + assert.equal(new Set(definitions.map(({ id }) => id)).size, 3); + assert.equal(new Set(definitions.map(({ label }) => label)).size, 3); + definitions.forEach((definition) => { + assert.equal( + definition.effect.activeThroughTurn, + dataModule.thirdCampPreparationActiveThroughTurn + ); + assert(definition.summary.length > 20); + assert(definition.deploymentLine.includes(definition.label)); + assert(definition.activeLine.includes(definition.label)); + assert(definition.appliedLine.includes(definition.label)); + assert(definition.unusedLine.includes(definition.label)); + }); + + const information = + dataModule.getThirdCampPreparationPriority('information'); + assert.equal(information?.effect.kind, 'marked-vanguard-intel'); + assert.equal(information?.effect.deploymentPreviewCount, 1); + assert.equal(information?.effect.accuracyBonus, 10); + assert.equal(information?.effect.maxBattleTriggers, 1); + assert.equal( + information?.effect.trackedEnemyUnitId, + dataModule.thirdCampPreparationInformationEnemyUnitId + ); + assert( + target.units.some( + (unit) => + unit.id === + dataModule.thirdCampPreparationInformationEnemyUnitId && + unit.faction === 'enemy' + ), + 'Information preparation must track a canonical fourth-battle enemy.' + ); + + const equipment = + dataModule.getThirdCampPreparationPriority('equipment'); + assert.equal(equipment?.effect.kind, 'prepared-equipment-guard'); + assert.equal(equipment?.effect.incomingDamageReductionPercent, 8); + assert.equal(equipment?.effect.maxBattleTriggers, 1); + assert.equal(equipment?.effect.targetRule, 'first-allied-hit'); + + const companion = + dataModule.getThirdCampPreparationPriority('companion'); + assert.equal(companion?.effect.kind, 'return-bond-opening'); + assert.equal(companion?.effect.cooperationRateBonus, 5); + + const cloned = + dataModule.getThirdCampPreparationPriority('information'); + cloned.label = 'mutated'; + cloned.effect.accuracyBonus = 99; + const fresh = + dataModule.getThirdCampPreparationPriority('information'); + assert.equal(fresh.label, '본영 정보'); + assert.equal(fresh.effect.accuracyBonus, 10); +} + +function verifyActivityGates(dataModule) { + const base = literalCampaign(dataModule); + let availability = + dataModule.resolveThirdCampPreparationAvailability(base); + assert.deepEqual( + availability.map(({ selectable, unavailableReason }) => ({ + selectable, + unavailableReason + })), + [ + { + selectable: false, + unavailableReason: 'report-review-required' + }, + { + selectable: false, + unavailableReason: 'equipment-change-required' + }, + { + selectable: false, + unavailableReason: 'priority-dialogue-required' + } + ] + ); + + const acknowledged = literalCampaign(dataModule, { + acknowledgedCategories: ['unlocks', 'equipment'] + }); + availability = + dataModule.resolveThirdCampPreparationAvailability(acknowledged); + assert.equal(availability[0].selectable, true); + assert.deepEqual(availability[0].evidence, { + kind: 'victory-reward-category', + battleId: dataModule.thirdCampPreparationSourceBattleId, + category: 'unlocks' + }); + assert.equal(availability[1].selectable, true); + assert.deepEqual(availability[1].evidence, { + kind: 'victory-reward-category', + battleId: dataModule.thirdCampPreparationSourceBattleId, + category: 'equipment' + }); + assert.equal(availability[2].selectable, false); + + const wrongBattleCategories = literalCampaign(dataModule); + wrongBattleCategories.acknowledgedVictoryRewardCategories = { + 'fourth-battle-guangzong-camp': ['unlocks', 'equipment'] + }; + availability = + dataModule.resolveThirdCampPreparationAvailability( + wrongBattleCategories + ); + assert.equal(availability[0].selectable, false); + assert.equal(availability[1].selectable, false); + + const companion = literalCampaign(dataModule, { + completePriorityDialogue: true + }); + availability = + dataModule.resolveThirdCampPreparationAvailability(companion); + const companionAvailability = availability.find( + ({ priority }) => priority.id === 'companion' + ); + assert.equal(companionAvailability?.selectable, true); + assert.equal( + companionAvailability?.evidence?.kind, + 'priority-return-dialogue' + ); + assert.equal( + companionAvailability?.evidence?.dialogueId, + 'guan-zhang-after-guangzong-road' + ); + assert.equal( + companionAvailability?.evidence?.bondId, + 'guan-yu__zhang-fei' + ); + + const unrelatedDialogue = literalCampaign(dataModule); + unrelatedDialogue.completedCampDialogues = [ + 'liu-guan-after-guangzong-road' + ]; + availability = + dataModule.resolveThirdCampPreparationAvailability( + unrelatedDialogue + ); + assert.equal( + availability.find(({ priority }) => priority.id === 'companion') + ?.selectable, + false, + 'Only the result-selected priority return dialogue may unlock companion preparation.' + ); + + const noVictory = literalCampaign(dataModule); + noVictory.battleHistory[ + dataModule.thirdCampPreparationSourceBattleId + ].outcome = 'defeat'; + availability = + dataModule.resolveThirdCampPreparationAvailability(noVictory); + assert( + availability.every( + ({ selectable, unavailableReason }) => + selectable === false && + unavailableReason === 'battle-record-required' + ) + ); +} + +function verifyTargetBattleMemoryIsolation(dataModule) { + dataModule.thirdCampPreparationPriorityIds.forEach((priorityId) => { + const campaign = eligibleLiteralCampaign( + dataModule, + priorityId + ); + const memory = dataModule.resolveThirdCampPreparationMemory({ + campaign, + battleId: dataModule.thirdCampPreparationTargetBattleId + }); + assert.equal(memory?.priorityId, priorityId); + assert.equal( + memory?.sourceBattleId, + dataModule.thirdCampPreparationSourceBattleId + ); + assert.equal( + memory?.targetBattleId, + dataModule.thirdCampPreparationTargetBattleId + ); + assert.equal( + memory?.selectionRecordId, + dataModule.thirdCampPreparationSelectionRecordId + ); + assert.equal( + dataModule.resolveThirdCampPreparationMemory({ + campaign, + battleId: dataModule.thirdCampPreparationSourceBattleId + }), + undefined + ); + assert.equal( + dataModule.resolveThirdCampPreparationMemory({ + campaign, + battleId: 'fifth-battle-sishui-vanguard' + }), + undefined + ); + }); + + const forged = eligibleLiteralCampaign(dataModule, 'information'); + forged.thirdCampPreparationSelection.priorityId = 'forged'; + assert.equal( + dataModule.resolveThirdCampPreparationMemory({ + campaign: forged, + battleId: dataModule.thirdCampPreparationTargetBattleId + }), + undefined + ); + + const stale = eligibleLiteralCampaign(dataModule, 'information'); + stale.acknowledgedVictoryRewardCategories = {}; + assert.equal( + dataModule.resolveThirdCampPreparationMemory({ + campaign: stale, + battleId: dataModule.thirdCampPreparationTargetBattleId + }), + undefined, + 'A stored choice without its persistent activity evidence must not create a battle effect.' + ); +} + +function verifyBattleLifecycleContract(dataModule) { + dataModule.thirdCampPreparationPriorityIds.forEach((priorityId) => { + const memory = dataModule.resolveThirdCampPreparationMemory({ + campaign: eligibleLiteralCampaign(dataModule, priorityId), + battleId: dataModule.thirdCampPreparationTargetBattleId + }); + assert(memory); + + const deployment = + dataModule.resolveThirdCampPreparationBattlePresentation({ + memory, + stage: 'deployment' + }); + const turnOne = + dataModule.resolveThirdCampPreparationBattlePresentation({ + memory, + stage: 'turn', + turnNumber: 1 + }); + const turnThree = + dataModule.resolveThirdCampPreparationBattlePresentation({ + memory, + stage: 'turn', + turnNumber: 3 + }); + const turnFour = + dataModule.resolveThirdCampPreparationBattlePresentation({ + memory, + stage: 'turn', + turnNumber: 4 + }); + const applied = + dataModule.resolveThirdCampPreparationBattlePresentation({ + memory, + stage: 'result', + usageCount: 2 + }); + const unused = + dataModule.resolveThirdCampPreparationBattlePresentation({ + memory, + stage: 'result', + usageCount: 0 + }); + + assert.deepEqual( + [ + deployment?.status, + turnOne?.status, + turnThree?.status, + turnFour?.status, + applied?.status, + unused?.status + ], + [ + 'deployment', + 'active', + 'active', + 'expired', + 'applied', + 'unused' + ] + ); + [ + deployment, + turnOne, + turnThree, + turnFour, + applied, + unused + ].forEach((presentation) => { + assert.equal( + presentation?.selectionRecordId, + memory.selectionRecordId + ); + assert.equal(presentation?.priorityId, memory.priorityId); + assert.equal(presentation?.label, memory.label); + assert.equal( + presentation?.activeThroughTurn, + dataModule.thirdCampPreparationActiveThroughTurn + ); + }); + assert.equal( + dataModule.resolveThirdCampPreparationBattlePresentation({ + memory, + stage: 'turn', + turnNumber: 0 + }), + undefined + ); + + const fresh = dataModule.resolveThirdCampPreparationMemory({ + campaign: eligibleLiteralCampaign(dataModule, priorityId), + battleId: dataModule.thirdCampPreparationTargetBattleId + }); + memory.label = 'mutated'; + if ('unitIds' in memory.evidence) { + memory.evidence.unitIds[0] = 'mutated'; + } + assert.notEqual(fresh?.label, 'mutated'); + if (fresh?.evidence.kind === 'priority-return-dialogue') { + assert.notEqual(fresh.evidence.unitIds[0], 'mutated'); + } + }); +} + +function verifyGuardedPersistentOverwrite( + dataModule, + actionModule, + campaignModule, + battleScenarios +) { + prepareThirdVictory(campaignModule, battleScenarios, dataModule); + + assert.equal( + actionModule.setThirdCampPreparationPriority('forged').reason, + 'invalid-priority' + ); + assert.equal( + actionModule.setThirdCampPreparationPriority('information').reason, + 'activity-required' + ); + + campaignModule.acknowledgeCampaignVictoryReward( + dataModule.thirdCampPreparationSourceBattleId, + ['unlocks'] + ); + let result = + actionModule.setThirdCampPreparationPriority('information'); + assert.equal(result.ok, true); + assert.equal(result.changed, true); + assert.equal(result.memory.priorityId, 'information'); + assert.deepEqual(result.campaign.thirdCampPreparationSelection, { + sourceBattleId: dataModule.thirdCampPreparationSourceBattleId, + targetBattleId: dataModule.thirdCampPreparationTargetBattleId, + priorityId: 'information' + }); + assert.equal( + result.campaign.completedCampVisits.filter( + (id) => id === dataModule.thirdCampPreparationSelectionRecordId + ).length, + 0, + 'Mutable preparation must never masquerade as a completed camp visit.' + ); + + assert.equal( + actionModule.setThirdCampPreparationPriority('equipment').reason, + 'activity-required' + ); + assert.equal( + campaignModule.getCampaignState() + .thirdCampPreparationSelection?.priorityId, + 'information', + 'A rejected replacement must preserve the previous valid priority.' + ); + + campaignModule.acknowledgeCampaignVictoryReward( + dataModule.thirdCampPreparationSourceBattleId, + ['equipment'] + ); + result = actionModule.setThirdCampPreparationPriority('equipment'); + assert.equal(result.ok, true); + assert.equal(result.changed, true); + assert.equal(result.memory.priorityId, 'equipment'); + + completePriorityDialogue( + dataModule, + campaignModule, + battleScenarios + ); + result = actionModule.setThirdCampPreparationPriority('companion'); + assert.equal(result.ok, true); + assert.equal(result.changed, true); + assert.equal(result.memory.priorityId, 'companion'); + assert.equal(result.memory.effect.bondId, 'guan-yu__zhang-fei'); + + const repeated = + actionModule.setThirdCampPreparationPriority('companion'); + assert.equal(repeated.ok, true); + assert.equal(repeated.changed, false); + + let reloaded = campaignModule.loadCampaignState(); + assert.equal( + reloaded.thirdCampPreparationSelection?.priorityId, + 'companion' + ); + assert.equal( + dataModule.resolveThirdCampPreparationMemory({ + campaign: reloaded, + battleId: dataModule.thirdCampPreparationTargetBattleId + })?.priorityId, + 'companion' + ); + + const cleared = actionModule.clearThirdCampPreparationPriority(); + assert.equal(cleared.ok, true); + assert.equal(cleared.changed, true); + assert.equal( + cleared.campaign.thirdCampPreparationSelection, + undefined + ); + assert.equal( + cleared.campaign.completedCampVisits.includes( + dataModule.thirdCampPreparationSelectionRecordId + ), + false + ); + assert.equal( + cleared.campaign.firstBattleReport?.completedCampVisits.includes( + dataModule.thirdCampPreparationSelectionRecordId + ), + false + ); + + result = actionModule.setThirdCampPreparationPriority('information'); + assert.equal(result.ok, true); + const beforeFailedOverwrite = campaignModule.getCampaignState(); + rejectStorageWrites = true; + let failedOverwrite; + try { + failedOverwrite = + actionModule.setThirdCampPreparationPriority('equipment'); + } finally { + rejectStorageWrites = false; + } + assert.equal(failedOverwrite?.ok, false); + assert.equal(failedOverwrite?.reason, 'save-unavailable'); + assert.equal( + campaignModule.getCampaignState() + .thirdCampPreparationSelection?.priorityId, + 'information' + ); + assert.equal( + JSON.stringify( + relevantSelectionState( + campaignModule.getCampaignState(), + dataModule + ) + ), + JSON.stringify(relevantSelectionState(beforeFailedOverwrite, dataModule)) + ); + + const wrongStep = campaignModule.getCampaignState(); + wrongStep.step = 'fourth-battle'; + campaignModule.setCampaignState(wrongStep); + assert.equal( + actionModule.setThirdCampPreparationPriority('equipment').reason, + 'invalid-campaign' + ); +} + +function verifyOldSaveAndPollutionSafety( + dataModule, + actionModule, + campaignModule, + battleScenarios +) { + prepareThirdVictory(campaignModule, battleScenarios, dataModule); + let state = campaignModule.getCampaignState(); + delete state.thirdCampPreparationSelection; + state.campVisitChoiceIds = { + 'existing-camp-visit': 'existing-choice' + }; + state.completedCampVisits = ['existing-camp-visit']; + state.firstBattleReport.completedCampVisits = [ + 'existing-camp-visit' + ]; + state = campaignModule.setCampaignState(state); + assert.equal(state.thirdCampPreparationSelection, undefined); + assert.equal( + state.campVisitChoiceIds['existing-camp-visit'], + 'existing-choice' + ); + assert.deepEqual(state.completedCampVisits, [ + 'existing-camp-visit' + ]); + assert.equal( + actionModule.thirdCampPreparationProgress(state) + .selectedPriorityId, + null + ); + + state.campVisitChoiceIds[ + dataModule.thirdCampPreparationSelectionRecordId + ] = 'information'; + state.completedCampVisits.push( + dataModule.thirdCampPreparationSelectionRecordId + ); + state.firstBattleReport.completedCampVisits.push( + dataModule.thirdCampPreparationSelectionRecordId + ); + state = campaignModule.setCampaignState(state); + assert.equal( + state.campVisitChoiceIds[ + dataModule.thirdCampPreparationSelectionRecordId + ], + undefined, + 'The abandoned camp-choice storage key must be scrubbed.' + ); + assert.equal(state.thirdCampPreparationSelection, undefined); + assert.equal( + state.completedCampVisits.includes( + dataModule.thirdCampPreparationSelectionRecordId + ), + false + ); + assert.equal( + state.firstBattleReport.completedCampVisits.includes( + dataModule.thirdCampPreparationSelectionRecordId + ), + false + ); + assert.equal( + state.campVisitChoiceIds['existing-camp-visit'], + 'existing-choice', + 'Legacy cleanup must not damage real camp history.' + ); + + const corruptSelections = [ + null, + 'information', + [], + {}, + { + sourceBattleId: 'fourth-battle-guangzong-camp', + targetBattleId: dataModule.thirdCampPreparationTargetBattleId, + priorityId: 'information' + }, + { + sourceBattleId: dataModule.thirdCampPreparationSourceBattleId, + targetBattleId: 'fifth-battle-sishui-vanguard', + priorityId: 'information' + }, + { + sourceBattleId: dataModule.thirdCampPreparationSourceBattleId, + targetBattleId: dataModule.thirdCampPreparationTargetBattleId, + priorityId: 'forged' + } + ]; + corruptSelections.forEach((selection) => { + const candidate = campaignModule.getCampaignState(); + candidate.thirdCampPreparationSelection = selection; + const normalized = campaignModule.setCampaignState(candidate); + assert.equal( + normalized.thirdCampPreparationSelection, + undefined + ); + }); + + state = campaignModule.getCampaignState(); + state.thirdCampPreparationSelection = { + sourceBattleId: dataModule.thirdCampPreparationSourceBattleId, + targetBattleId: dataModule.thirdCampPreparationTargetBattleId, + priorityId: 'information' + }; + state = campaignModule.setCampaignState(state); + assert.equal( + state.thirdCampPreparationSelection, + undefined, + 'A structurally valid but activity-locked priority must be removed.' + ); + + campaignModule.acknowledgeCampaignVictoryReward( + dataModule.thirdCampPreparationSourceBattleId, + ['unlocks'] + ); + const validCandidate = campaignModule.getCampaignState(); + validCandidate.thirdCampPreparationSelection = { + sourceBattleId: dataModule.thirdCampPreparationSourceBattleId, + targetBattleId: dataModule.thirdCampPreparationTargetBattleId, + priorityId: 'information', + polluted: true + }; + state = campaignModule.setCampaignState(validCandidate); + assert.deepEqual(state.thirdCampPreparationSelection, { + sourceBattleId: dataModule.thirdCampPreparationSourceBattleId, + targetBattleId: dataModule.thirdCampPreparationTargetBattleId, + priorityId: 'information' + }); + validCandidate.thirdCampPreparationSelection.priorityId = + 'equipment'; + assert.equal( + campaignModule.getCampaignState() + .thirdCampPreparationSelection?.priorityId, + 'information', + 'The normalized selection must be deep-cloned from caller state.' + ); + + state = campaignModule.getCampaignState(); + state.thirdCampPreparationSelection = { + sourceBattleId: dataModule.thirdCampPreparationSourceBattleId, + targetBattleId: dataModule.thirdCampPreparationTargetBattleId, + priorityId: 'equipment' + }; + state = campaignModule.setCampaignState(state); + assert.equal( + state.thirdCampPreparationSelection, + undefined, + 'A different locked priority must not borrow another activity proof.' + ); + assert.equal( + dataModule.resolveThirdCampPreparationSelection(state), + undefined + ); + assert.equal( + dataModule.resolveThirdCampPreparationMemory({ + campaign: state, + battleId: dataModule.thirdCampPreparationTargetBattleId + }), + undefined + ); + + const legacy = literalCampaign(dataModule, { + acknowledgedCategories: ['unlocks'], + selectedPriorityId: 'information', + legacyReportOnly: true + }); + const legacyMemory = + dataModule.resolveThirdCampPreparationMemory({ + campaign: legacy, + battleId: dataModule.thirdCampPreparationTargetBattleId + }); + assert.equal(legacyMemory?.priorityId, 'information'); + assert.equal( + legacyMemory?.evidence.kind, + 'victory-reward-category' + ); + + const legacyCompanion = literalCampaign(dataModule, { + completePriorityDialogue: true, + selectedPriorityId: 'companion', + legacyReportOnly: true + }); + assert.equal( + dataModule.resolveThirdCampPreparationMemory({ + campaign: legacyCompanion, + battleId: dataModule.thirdCampPreparationTargetBattleId + })?.priorityId, + 'companion' + ); + + const legacyWrongReport = structuredClone(legacy); + legacyWrongReport.firstBattleReport.battleId = + 'fourth-battle-guangzong-camp'; + assert.equal( + dataModule.resolveThirdCampPreparationMemory({ + campaign: legacyWrongReport, + battleId: dataModule.thirdCampPreparationTargetBattleId + }), + undefined + ); +} + +function prepareThirdVictory( + campaignModule, + battleScenarios, + dataModule +) { + storage.clear(); + campaignModule.resetCampaignState(); + const scenario = + battleScenarios[dataModule.thirdCampPreparationSourceBattleId]; + campaignModule.setFirstBattleReport({ + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: 22, + rewardGold: scenario.baseVictoryGold, + defeatedEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + totalEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + objectives: scenario.objectives.map((objective) => ({ + id: objective.id, + label: objective.label, + achieved: true, + status: 'done', + detail: '달성', + rewardGold: objective.rewardGold + })), + units: scenario.units, + bonds: scenario.bonds.map((bond) => ({ + ...bond, + battleExp: 0 + })), + itemRewards: [...scenario.itemRewards], + campaignRewards: { + supplies: [...(scenario.campaignReward?.supplies ?? [])], + equipment: [...(scenario.campaignReward?.equipment ?? [])], + reputation: [...(scenario.campaignReward?.reputation ?? [])], + recruits: [], + unlocks: scenario.campaignReward?.unlockBattleId + ? [{ + battleId: scenario.campaignReward.unlockBattleId, + title: + scenario.campaignReward.unlockLabel ?? + scenario.campaignReward.unlockBattleId + }] + : [], + note: scenario.campaignReward?.note + }, + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-27T00:00:00.000Z' + }); + const campaign = campaignModule.getCampaignState(); + assert.equal(campaign.step, 'third-camp'); + assert.equal( + campaign.latestBattleId, + dataModule.thirdCampPreparationSourceBattleId + ); + return campaign; +} + +function completePriorityDialogue( + dataModule, + campaignModule, + battleScenarios +) { + const campaign = campaignModule.getCampaignState(); + const dialogueModuleResult = priorityDialogueForCampaign( + campaign, + dataModule + ); + const bondByDialogueId = { + 'liu-guan-after-guangzong-road': 'liu-bei__guan-yu', + 'liu-zhang-after-guangzong-road': 'liu-bei__zhang-fei', + 'guan-zhang-after-guangzong-road': 'guan-yu__zhang-fei' + }; + const bondId = bondByDialogueId[dialogueModuleResult.targetDialogueId]; + assert( + battleScenarios[ + dataModule.thirdCampPreparationSourceBattleId + ].bonds.some((bond) => bond.id === bondId) + ); + const report = campaignModule.applyCampBondExp( + dialogueModuleResult.targetDialogueId, + bondId, + 1, + 'review-third-battle-result' + ); + assert( + report?.completedCampDialogues.includes( + dialogueModuleResult.targetDialogueId + ) + ); +} + +function priorityDialogueForCampaign(campaign, dataModule) { + const record = + campaign.battleHistory[ + dataModule.thirdCampPreparationSourceBattleId + ] ?? campaign.firstBattleReport; + const fort = record.objectives.find(({ id }) => id === 'fort'); + const quick = record.objectives.find(({ id }) => id === 'quick'); + if (!fort.achieved) { + return { + targetDialogueId: 'liu-guan-after-guangzong-road' + }; + } + if (!quick.achieved) { + return { + targetDialogueId: 'liu-zhang-after-guangzong-road' + }; + } + return { + targetDialogueId: 'guan-zhang-after-guangzong-road' + }; +} + +function eligibleLiteralCampaign(dataModule, priorityId) { + return literalCampaign(dataModule, { + acknowledgedCategories: + priorityId === 'information' + ? ['unlocks'] + : priorityId === 'equipment' + ? ['equipment'] + : [], + completePriorityDialogue: priorityId === 'companion', + selectedPriorityId: priorityId + }); +} + +function literalCampaign( + dataModule, + { + acknowledgedCategories = [], + completePriorityDialogue = false, + selectedPriorityId, + legacyReportOnly = false + } = {} +) { + const report = { + battleId: dataModule.thirdCampPreparationSourceBattleId, + outcome: 'victory', + turnNumber: 22, + objectives: [ + { id: 'fort', achieved: true, status: 'done' }, + { id: 'quick', achieved: true, status: 'done' } + ], + units: [] + }; + return { + step: 'third-camp', + latestBattleId: dataModule.thirdCampPreparationSourceBattleId, + battleHistory: legacyReportOnly + ? {} + : { + [dataModule.thirdCampPreparationSourceBattleId]: + structuredClone(report) + }, + firstBattleReport: structuredClone(report), + completedCampDialogues: completePriorityDialogue + ? ['guan-zhang-after-guangzong-road'] + : [], + completedCampVisits: [], + campVisitChoiceIds: {}, + ...(selectedPriorityId + ? { + thirdCampPreparationSelection: { + sourceBattleId: + dataModule.thirdCampPreparationSourceBattleId, + targetBattleId: + dataModule.thirdCampPreparationTargetBattleId, + priorityId: selectedPriorityId + } + } + : {}), + acknowledgedVictoryRewardCategories: + acknowledgedCategories.length > 0 + ? { + [dataModule.thirdCampPreparationSourceBattleId]: + [...acknowledgedCategories] + } + : {} + }; +} + +function relevantSelectionState(campaign, dataModule) { + return { + selection: campaign.thirdCampPreparationSelection ?? null, + completed: campaign.completedCampVisits.filter( + (id) => id === dataModule.thirdCampPreparationSelectionRecordId + ), + reportCompleted: + campaign.firstBattleReport?.completedCampVisits.filter( + (id) => id === dataModule.thirdCampPreparationSelectionRecordId + ) ?? [] + }; +} diff --git a/src/game/data/thirdCampPreparation.ts b/src/game/data/thirdCampPreparation.ts new file mode 100644 index 0000000..086c7cd --- /dev/null +++ b/src/game/data/thirdCampPreparation.ts @@ -0,0 +1,639 @@ +import { + resolveThirdBattlePriorityReturnDialogue, + thirdBattleReturnSourceBattleId +} from './thirdBattleReturnDialogue'; + +export const thirdCampPreparationSourceBattleId = + thirdBattleReturnSourceBattleId; +export const thirdCampPreparationTargetBattleId = + 'fourth-battle-guangzong-camp'; +export const thirdCampPreparationSelectionRecordId = + 'third-camp-preparation-priority'; +export const thirdCampPreparationActiveThroughTurn = 3; +export const thirdCampPreparationInformationEnemyUnitId = + 'guangzong-main-vanguard-a'; +export const thirdCampPreparationPriorityIds = [ + 'information', + 'equipment', + 'companion' +] as const; + +export type ThirdCampPreparationPriorityId = + (typeof thirdCampPreparationPriorityIds)[number]; + +export type ThirdCampPreparationInformationEffect = { + kind: 'marked-vanguard-intel'; + activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn; + deploymentPreviewCount: 1; + trackedEnemyUnitId: + typeof thirdCampPreparationInformationEnemyUnitId; + accuracyBonus: 10; + maxBattleTriggers: 1; +}; + +export type ThirdCampPreparationEquipmentEffect = { + kind: 'prepared-equipment-guard'; + activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn; + incomingDamageReductionPercent: 8; + minimumDamage: 1; + maxBattleTriggers: 1; + targetRule: 'first-allied-hit'; +}; + +export type ThirdCampPreparationCompanionEffect = { + kind: 'return-bond-opening'; + activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn; + cooperationRateBonus: 5; +}; + +export type ThirdCampPreparationEffect = + | ThirdCampPreparationInformationEffect + | ThirdCampPreparationEquipmentEffect + | ThirdCampPreparationCompanionEffect; + +export type ThirdCampPreparationPriorityDefinition = { + id: ThirdCampPreparationPriorityId; + label: string; + activityLabel: string; + summary: string; + deploymentLine: string; + activeLine: string; + appliedLine: string; + unusedLine: string; + effect: ThirdCampPreparationEffect; +}; + +export const thirdCampPreparationPriorities: readonly + ThirdCampPreparationPriorityDefinition[] = [ + { + id: 'information', + label: '본영 정보', + activityLabel: '승전 보고 확인', + summary: + '광종 본영 선봉 한 명의 첫 행동을 배치 때 확인하고, 3턴 안 첫 공격 명중을 10%p 높입니다.', + deploymentLine: + '본영 정보 · 선봉 적 1명의 의도와 표식을 배치 단계에서 먼저 확인', + activeLine: + '본영 정보 · 첫 3턴 안 표식 대상 첫 공격 명중 +10%p', + appliedLine: + '본영 정보 · 표식 대상 첫 공격 명중 보정 적용', + unusedLine: + '본영 정보 · 표식 대상을 공격하기 전에 준비 시간이 끝남', + effect: { + kind: 'marked-vanguard-intel', + activeThroughTurn: thirdCampPreparationActiveThroughTurn, + deploymentPreviewCount: 1, + trackedEnemyUnitId: + thirdCampPreparationInformationEnemyUnitId, + accuracyBonus: 10, + maxBattleTriggers: 1 + } + }, + { + id: 'equipment', + label: '장비 정비', + activityLabel: '승전 장비 확인', + summary: + '3차 승전 장비를 확인하면 첫 3턴 아군의 첫 피격 피해를 한 번 8% 줄입니다.', + deploymentLine: + '장비 정비 · 아군 첫 피격 1회 방호 준비', + activeLine: + '장비 정비 · 첫 3턴 동안 아군 첫 피격 1회 피해 -8%', + appliedLine: + '장비 정비 · 아군 첫 피격 방호 적용', + unusedLine: + '장비 정비 · 방호가 발동하기 전에 준비 시간이 끝남', + effect: { + kind: 'prepared-equipment-guard', + activeThroughTurn: thirdCampPreparationActiveThroughTurn, + incomingDamageReductionPercent: 8, + minimumDamage: 1, + maxBattleTriggers: 1, + targetRule: 'first-allied-hit' + } + }, + { + id: 'companion', + label: '동료 공명', + activityLabel: '우선 귀환 대화', + summary: + '우선 귀환 대화를 나눈 공명조는 첫 3턴 협공 확률이 5%p 오릅니다.', + deploymentLine: + '동료 공명 · 귀환 대화 공명조의 초반 협공 신호 준비', + activeLine: + '동료 공명 · 첫 3턴 동안 해당 공명조 협공 확률 +5%p', + appliedLine: + '동료 공명 · 귀환 대화에서 맞춘 협공 신호 발동', + unusedLine: + '동료 공명 · 해당 공명조가 초반 협공을 잇지 못함', + effect: { + kind: 'return-bond-opening', + activeThroughTurn: thirdCampPreparationActiveThroughTurn, + cooperationRateBonus: 5 + } + } +] as const; + +export type ThirdCampPreparationEvidence = + | { + kind: 'victory-reward-category'; + battleId: typeof thirdCampPreparationSourceBattleId; + category: 'unlocks' | 'equipment'; + } + | { + kind: 'priority-return-dialogue'; + battleId: typeof thirdCampPreparationSourceBattleId; + dialogueId: string; + bondId: string; + unitIds: [string, string]; + }; + +export type ThirdCampPreparationUnavailableReason = + | 'battle-record-required' + | 'report-review-required' + | 'equipment-change-required' + | 'priority-dialogue-required'; + +export type ThirdCampPreparationAvailability = { + priority: ThirdCampPreparationPriorityDefinition; + selectable: boolean; + evidence?: ThirdCampPreparationEvidence; + unavailableReason?: ThirdCampPreparationUnavailableReason; +}; + +export type ResolvedThirdCampPreparationEffect = + | ThirdCampPreparationInformationEffect + | ThirdCampPreparationEquipmentEffect + | (ThirdCampPreparationCompanionEffect & { + bondId: string; + unitIds: [string, string]; + }); + +export type ThirdCampPreparationMemory = { + sourceBattleId: typeof thirdCampPreparationSourceBattleId; + targetBattleId: typeof thirdCampPreparationTargetBattleId; + selectionRecordId: typeof thirdCampPreparationSelectionRecordId; + priorityId: ThirdCampPreparationPriorityId; + label: string; + summary: string; + deploymentLine: string; + activeLine: string; + appliedLine: string; + unusedLine: string; + evidence: ThirdCampPreparationEvidence; + effect: ResolvedThirdCampPreparationEffect; +}; + +export type ThirdCampPreparationBattlePresentation = { + selectionRecordId: typeof thirdCampPreparationSelectionRecordId; + priorityId: ThirdCampPreparationPriorityId; + label: string; + status: + | 'deployment' + | 'active' + | 'expired' + | 'applied' + | 'unused'; + text: string; + activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn; + turnNumber: number | null; + usageCount: number; +}; + +export type ThirdCampPreparationCampaignState = { + step?: unknown; + latestBattleId?: unknown; + battleHistory?: Readonly< + Record + >; + firstBattleReport?: ThirdCampPreparationBattleRecord | null; + completedCampDialogues?: readonly unknown[]; + thirdCampPreparationSelection?: { + sourceBattleId?: unknown; + targetBattleId?: unknown; + priorityId?: unknown; + } | null; + acknowledgedVictoryRewardCategories?: Readonly< + Record + >; +}; + +type ThirdCampPreparationBattleRecord = { + battleId?: unknown; + outcome?: unknown; + turnNumber?: unknown; + objectives?: unknown; + units?: unknown; +}; + +type ThirdBattleRecordSelection = { + record: ThirdCampPreparationBattleRecord; + source: 'battle-history' | 'legacy-report'; +}; + +const companionBondByDialogueId: Readonly< + Record +> = { + 'liu-guan-after-guangzong-road': { + bondId: 'liu-bei__guan-yu', + unitIds: ['liu-bei', 'guan-yu'] + }, + 'liu-zhang-after-guangzong-road': { + bondId: 'liu-bei__zhang-fei', + unitIds: ['liu-bei', 'zhang-fei'] + }, + 'guan-zhang-after-guangzong-road': { + bondId: 'guan-yu__zhang-fei', + unitIds: ['guan-yu', 'zhang-fei'] + } +}; + +export function isThirdCampPreparationPriorityId( + value: unknown +): value is ThirdCampPreparationPriorityId { + return ( + typeof value === 'string' && + (thirdCampPreparationPriorityIds as readonly string[]).includes( + value + ) + ); +} + +export function getThirdCampPreparationPriority( + priorityId: unknown +): ThirdCampPreparationPriorityDefinition | undefined { + const definition = isThirdCampPreparationPriorityId(priorityId) + ? thirdCampPreparationPriorities.find( + (candidate) => candidate.id === priorityId + ) + : undefined; + return definition ? clonePriority(definition) : undefined; +} + +export function hasThirdCampPreparationSourceVictory( + campaign?: ThirdCampPreparationCampaignState | null +) { + return Boolean(selectThirdBattleVictoryRecord(campaign)); +} + +export function resolveThirdCampPreparationAvailability( + campaign?: ThirdCampPreparationCampaignState | null +): ThirdCampPreparationAvailability[] { + const source = selectThirdBattleVictoryRecord(campaign); + if (!source) { + return thirdCampPreparationPriorities.map((priority) => ({ + priority: clonePriority(priority), + selectable: false, + unavailableReason: 'battle-record-required' + })); + } + + const informationEvidence = acknowledgedRewardEvidence( + campaign, + 'unlocks' + ); + const equipmentEvidence = acknowledgedRewardEvidence( + campaign, + 'equipment' + ); + const companionEvidence = completedCompanionEvidence(campaign); + const evidenceByPriority: Partial< + Record + > = { + ...(informationEvidence + ? { information: informationEvidence } + : {}), + ...(equipmentEvidence ? { equipment: equipmentEvidence } : {}), + ...(companionEvidence ? { companion: companionEvidence } : {}) + }; + const missingReasonByPriority: Record< + ThirdCampPreparationPriorityId, + ThirdCampPreparationUnavailableReason + > = { + information: 'report-review-required', + equipment: 'equipment-change-required', + companion: 'priority-dialogue-required' + }; + + return thirdCampPreparationPriorities.map((priority) => { + const evidence = evidenceByPriority[priority.id]; + return { + priority: clonePriority(priority), + selectable: Boolean(evidence), + ...(evidence + ? { evidence: cloneEvidence(evidence) } + : { + unavailableReason: missingReasonByPriority[priority.id] + }) + }; + }); +} + +export function resolveThirdCampPreparationSelection( + campaign?: ThirdCampPreparationCampaignState | null +) { + const selection = campaign?.thirdCampPreparationSelection; + if ( + selection?.sourceBattleId !== + thirdCampPreparationSourceBattleId || + selection.targetBattleId !== + thirdCampPreparationTargetBattleId + ) { + return undefined; + } + const priorityId = selection.priorityId; + if (!isThirdCampPreparationPriorityId(priorityId)) { + return undefined; + } + const availability = resolveThirdCampPreparationAvailability( + campaign + ).find((entry) => entry.priority.id === priorityId); + return availability + ? { + priorityId, + label: availability.priority.label, + selectable: availability.selectable, + ...(availability.evidence + ? { evidence: cloneEvidence(availability.evidence) } + : {}), + ...(availability.unavailableReason + ? { unavailableReason: availability.unavailableReason } + : {}) + } + : undefined; +} + +export function resolveThirdCampPreparationMemory(options: { + campaign?: ThirdCampPreparationCampaignState | null; + battleId?: string; +}): ThirdCampPreparationMemory | undefined { + if (options.battleId !== thirdCampPreparationTargetBattleId) { + return undefined; + } + const selection = resolveThirdCampPreparationSelection( + options.campaign + ); + if (!selection?.selectable || !selection.evidence) { + return undefined; + } + const priority = getThirdCampPreparationPriority( + selection.priorityId + ); + if (!priority) { + return undefined; + } + const effect = resolveEffect(priority.effect, selection.evidence); + if (!effect) { + return undefined; + } + + return { + sourceBattleId: thirdCampPreparationSourceBattleId, + targetBattleId: thirdCampPreparationTargetBattleId, + selectionRecordId: thirdCampPreparationSelectionRecordId, + priorityId: priority.id, + label: priority.label, + summary: priority.summary, + deploymentLine: priority.deploymentLine, + activeLine: priority.activeLine, + appliedLine: priority.appliedLine, + unusedLine: priority.unusedLine, + evidence: cloneEvidence(selection.evidence), + effect + }; +} + +export function resolveThirdCampPreparationBattlePresentation(options: { + memory: ThirdCampPreparationMemory; + stage: 'deployment' | 'turn' | 'result'; + turnNumber?: number; + usageCount?: number; +}): ThirdCampPreparationBattlePresentation | undefined { + const priority = getThirdCampPreparationPriority( + options.memory.priorityId + ); + if ( + !priority || + priority.label !== options.memory.label || + options.memory.targetBattleId !== + thirdCampPreparationTargetBattleId + ) { + return undefined; + } + + const usageCount = nonNegativeInteger(options.usageCount); + if (options.stage === 'deployment') { + return presentationBase( + options.memory, + 'deployment', + options.memory.deploymentLine, + null, + usageCount + ); + } + if (options.stage === 'turn') { + const turnNumber = positiveInteger(options.turnNumber); + if (!turnNumber) { + return undefined; + } + const active = + turnNumber <= thirdCampPreparationActiveThroughTurn; + return presentationBase( + options.memory, + active ? 'active' : 'expired', + active + ? `${options.memory.activeLine} · ${turnNumber}/${thirdCampPreparationActiveThroughTurn}턴` + : `${options.memory.label} · 준비 효과 종료`, + turnNumber, + usageCount + ); + } + + const applied = usageCount > 0; + return presentationBase( + options.memory, + applied ? 'applied' : 'unused', + applied + ? `${options.memory.appliedLine} · ${usageCount}회` + : options.memory.unusedLine, + null, + usageCount + ); +} + +function selectThirdBattleVictoryRecord( + campaign?: ThirdCampPreparationCampaignState | null +): ThirdBattleRecordSelection | undefined { + const history = campaign?.battleHistory; + if ( + history && + Object.prototype.hasOwnProperty.call( + history, + thirdCampPreparationSourceBattleId + ) + ) { + const record = history[thirdCampPreparationSourceBattleId]; + return validThirdBattleVictory(record) + ? { record, source: 'battle-history' } + : undefined; + } + + const record = campaign?.firstBattleReport; + return validThirdBattleVictory(record) + ? { record, source: 'legacy-report' } + : undefined; +} + +function validThirdBattleVictory( + record?: ThirdCampPreparationBattleRecord | null +): record is ThirdCampPreparationBattleRecord { + return Boolean( + record && + record.battleId === thirdCampPreparationSourceBattleId && + record.outcome === 'victory' + ); +} + +function acknowledgedRewardEvidence( + campaign: ThirdCampPreparationCampaignState | null | undefined, + category: 'unlocks' | 'equipment' +): Extract< + ThirdCampPreparationEvidence, + { kind: 'victory-reward-category' } +> | undefined { + const acknowledged = + campaign?.acknowledgedVictoryRewardCategories?.[ + thirdCampPreparationSourceBattleId + ]; + return Array.isArray(acknowledged) && + acknowledged.includes(category) + ? { + kind: 'victory-reward-category', + battleId: thirdCampPreparationSourceBattleId, + category + } + : undefined; +} + +function completedCompanionEvidence( + campaign?: ThirdCampPreparationCampaignState | null +): Extract< + ThirdCampPreparationEvidence, + { kind: 'priority-return-dialogue' } +> | undefined { + const dialogue = resolveThirdBattlePriorityReturnDialogue(campaign); + if ( + !dialogue || + !arrayValue(campaign?.completedCampDialogues).includes( + dialogue.targetDialogueId + ) + ) { + return undefined; + } + const bond = companionBondByDialogueId[dialogue.targetDialogueId]; + return bond + ? { + kind: 'priority-return-dialogue', + battleId: thirdCampPreparationSourceBattleId, + dialogueId: dialogue.targetDialogueId, + bondId: bond.bondId, + unitIds: [...bond.unitIds] + } + : undefined; +} + +function resolveEffect( + effect: ThirdCampPreparationEffect, + evidence: ThirdCampPreparationEvidence +): ResolvedThirdCampPreparationEffect | undefined { + if ( + effect.kind === 'marked-vanguard-intel' && + evidence.kind === 'victory-reward-category' && + evidence.category === 'unlocks' + ) { + return { ...effect }; + } + if ( + effect.kind === 'prepared-equipment-guard' && + evidence.kind === 'victory-reward-category' && + evidence.category === 'equipment' + ) { + return { ...effect }; + } + if ( + effect.kind === 'return-bond-opening' && + evidence.kind === 'priority-return-dialogue' + ) { + return { + ...effect, + bondId: evidence.bondId, + unitIds: [...evidence.unitIds] + }; + } + return undefined; +} + +function presentationBase( + memory: ThirdCampPreparationMemory, + status: ThirdCampPreparationBattlePresentation['status'], + text: string, + turnNumber: number | null, + usageCount: number +): ThirdCampPreparationBattlePresentation { + return { + selectionRecordId: thirdCampPreparationSelectionRecordId, + priorityId: memory.priorityId, + label: memory.label, + status, + text, + activeThroughTurn: thirdCampPreparationActiveThroughTurn, + turnNumber, + usageCount + }; +} + +function clonePriority( + priority: ThirdCampPreparationPriorityDefinition +): ThirdCampPreparationPriorityDefinition { + return { + ...priority, + effect: { ...priority.effect } + } as ThirdCampPreparationPriorityDefinition; +} + +function cloneEvidence( + evidence: ThirdCampPreparationEvidence +): ThirdCampPreparationEvidence { + if (evidence.kind === 'priority-return-dialogue') { + return { + ...evidence, + unitIds: [...evidence.unitIds] + }; + } + return { ...evidence }; +} + +function positiveInteger(value: unknown) { + return ( + typeof value === 'number' && + Number.isInteger(value) && + value > 0 && + value <= 9999 + ) + ? value + : undefined; +} + +function nonNegativeInteger(value: unknown) { + return ( + typeof value === 'number' && + Number.isFinite(value) && + value > 0 + ) + ? Math.min(999999, Math.floor(value)) + : 0; +} + +function arrayValue(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 51c2335..453aa95 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -132,6 +132,16 @@ import { resolveSecondBattleReliefMemory, type SecondBattleReliefMemory } from '../data/secondBattleReliefMemory'; +import { + resolveThirdCampPreparationBattlePresentation, + resolveThirdCampPreparationMemory, + thirdCampPreparationActiveThroughTurn, + thirdCampPreparationSelectionRecordId, + thirdCampPreparationSourceBattleId, + thirdCampPreparationTargetBattleId, + type ThirdCampPreparationBattlePresentation, + type ThirdCampPreparationMemory +} from '../data/thirdCampPreparation'; import { evaluateSortieOrder, evaluateSortieOrderProgress, @@ -180,6 +190,7 @@ import { type BattleSaveState, type BattleSaveTacticalCommandRole, type BattleSaveTacticalCommandState, + type BattleSaveThirdCampPreparationState, type BattleSaveUnitStats } from '../state/battleSaveState'; import { @@ -1716,6 +1727,21 @@ type SortieCooperationStats = { additionalDamage: number; }; +type ThirdCampPreparationRuntimeState = { + informationConsumed: boolean; + equipmentConsumed: boolean; + companionAttempts: number; + companionSuccesses: number; + usageCount: number; + preventedDamage: number; +}; + +type ThirdCampPreparationCombatEffect = { + hitBonus: number; + damageReduction: number; + labels: string[]; +}; + type BondCombatBonus = { damageBonus: number; chainRate: number; @@ -1729,6 +1755,7 @@ type BondChainCandidate = { label: string; partner: UnitData; rate: number; + preparationRateBonus: number; damage: number; coreResonance: boolean; }; @@ -1751,6 +1778,7 @@ type BondChainAttempt = { partnerId: string; partnerName: string; rate: number; + preparationRateBonus: number; expectedDamage: number; succeeded: boolean; coreResonance: boolean; @@ -1983,6 +2011,7 @@ type CombatPreview = { bondPartnerIds: string[]; bondExtendedRange: boolean; bondChainRate: number; + bondChainPreparationRateBonus: number; bondChainDamage: number; bondChainBondId?: string; bondChainPartnerId?: string; @@ -2005,6 +2034,10 @@ type CombatPreview = { initiativeDamageReduction: number; initiativeHitBonus: number; initiativeEffectLabels: string[]; + preparationHitBonus: number; + preparationDamageReduction: number; + preparationEffectLabels: string[]; + damageBeforePreparation: number; damageBeforeInitiative: number; hitRate: number; criticalRate: number; @@ -2040,6 +2073,7 @@ type CombatResult = CombatPreview & { sortiePreventedDamageAmount: number; initiativeBonusDamageAmount: number; initiativePreventedDamageAmount: number; + preparationPreventedDamageAmount: number; initiativeReadied: boolean; bondChainAttempt?: BondChainAttempt; bondChain?: BondChainResult; @@ -3903,6 +3937,16 @@ export class BattleScene extends Phaser.Scene { private firstBattlePreparation: FirstBattlePreparationState = { ...emptyFirstBattlePreparationState }; private firstPursuitScoutMemory?: FirstPursuitScoutMemory; private secondBattleReliefMemory?: SecondBattleReliefMemory; + private thirdCampPreparationMemory?: ThirdCampPreparationMemory; + private thirdCampPreparationRuntime: ThirdCampPreparationRuntimeState = { + informationConsumed: false, + equipmentConsumed: false, + companionAttempts: 0, + companionSuccesses: 0, + usageCount: 0, + preventedDamage: 0 + }; + private thirdCampPreparationChipBounds?: HudBounds; private prologueVolunteerReassured = false; private coreResonancePursuitAttempts = 0; private coreResonancePursuitSuccesses = 0; @@ -3932,6 +3976,8 @@ export class BattleScene extends Phaser.Scene { this.scenarioCombatAssetSettlementCount = 0; this.pendingDeploymentConfirmation = false; this.pendingDeploymentSignature = undefined; + this.turnNumber = 1; + this.activeFaction = 'ally'; this.rosterTab = 'ally'; this.rosterPage = 0; this.bondPage = 0; @@ -4019,6 +4065,12 @@ export class BattleScene extends Phaser.Scene { campaign, battleId: battleScenario.id }); + this.thirdCampPreparationMemory = resolveThirdCampPreparationMemory({ + campaign, + battleId: battleScenario.id + }); + this.resetThirdCampPreparationRuntime(); + this.thirdCampPreparationChipBounds = undefined; this.prologueVolunteerReassured = campaign.completedTutorialIds.includes( prologueMilitiaCampCampaignTutorialIds.reassureVolunteer ); @@ -6214,6 +6266,16 @@ export class BattleScene extends Phaser.Scene { private renderTacticalInitiativeChip() { this.tacticalInitiativeChipObjects.forEach((object) => object.destroy()); this.tacticalInitiativeChipObjects = []; + this.thirdCampPreparationChipBounds = undefined; + if ( + this.thirdCampPreparationMemory && + this.phase !== 'deployment' && + this.phase !== 'resolved' && + !this.battleOutcome + ) { + this.renderThirdCampPreparationChip(); + return; + } if ( !this.isFirstPursuitRoleEffectBattle() || this.phase === 'deployment' || @@ -6287,6 +6349,97 @@ export class BattleScene extends Phaser.Scene { this.tacticalInitiativeChipObjects.push(button, text); } + private renderThirdCampPreparationChip() { + const presentation = + this.thirdCampPreparationPresentation('turn'); + const label = + this.thirdCampPreparationChipLabel(presentation); + if (!presentation || !label) { + return; + } + const { panelX, panelY, panelWidth } = this.layout; + const width = this.battleUiLength(240); + const height = this.battleUiLength(30); + const left = + panelX + panelWidth - width - this.battleUiLength(24); + const top = panelY + this.battleUiLength(26); + const applied = + this.thirdCampPreparationRuntime.usageCount > 0; + const active = presentation.status === 'active'; + const tone = applied + ? palette.green + : active + ? palette.gold + : 0x647485; + const background = this.add.rectangle( + left, + top, + width, + height, + active ? 0x2d2619 : 0x17232e, + 0.98 + ); + background.setOrigin(0); + background.setDepth(16); + background.setStrokeStyle( + this.battleUiLength(1), + tone, + active || applied ? 0.92 : 0.62 + ); + const text = this.add.text( + left + width / 2, + top + height / 2 - this.battleUiLength(1), + label, + { + fontFamily: + '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(11), + color: applied + ? '#a8ffd0' + : active + ? '#fff2b8' + : '#9fb0bf', + fontStyle: '700' + } + ); + text.setOrigin(0.5); + text.setDepth(17); + const bounds = background.getBounds(); + this.thirdCampPreparationChipBounds = { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + }; + this.tacticalInitiativeChipObjects.push(background, text); + } + + private thirdCampPreparationChipLabel( + presentation = + this.thirdCampPreparationPresentation('turn') + ) { + if (!presentation) { + return undefined; + } + const applied = + this.thirdCampPreparationRuntime.usageCount > 0; + const active = presentation.status === 'active'; + if ( + applied && + presentation.priorityId === 'companion' && + active + ) { + return `준비 ${presentation.label} · 시도 ${this.thirdCampPreparationRuntime.usageCount}회 · ${this.turnNumber}/${presentation.activeThroughTurn}턴`; + } + if (applied) { + return `준비 ${presentation.label} · 사용 완료`; + } + if (active) { + return `준비 ${presentation.label} · ${this.turnNumber}/${presentation.activeThroughTurn}턴`; + } + return `준비 ${presentation.label} · 종료`; + } + private showTacticalInitiativePanel() { if ( !battleScenario.sortie || @@ -8603,6 +8756,9 @@ export class BattleScene extends Phaser.Scene { if (this.firstPursuitScoutMemory) { return `${this.firstPursuitScoutMemory.campSummaryLine}. 간옹이 표시한 적의 첫 움직임을 보며 전열을 조정하세요.`; } + if (this.thirdCampPreparationMemory) { + return `${this.thirdCampPreparationMemory.deploymentLine}. 효과는 3턴이 끝나면 종료됩니다.`; + } if (this.secondBattleReliefMemory) { return this.secondBattleReliefMemory.effect.kind === 'village-supply-line' ? `${this.secondBattleReliefMemory.campSummaryLine}. 유비를 후방에 두면 마을에서 챙긴 콩 1개를 전투 중 사용할 수 있습니다.` @@ -8649,6 +8805,15 @@ export class BattleScene extends Phaser.Scene { })(); return `${this.secondBattleReliefMemory.campSummaryLine}\n${strategySummary}`; } + if (this.thirdCampPreparationMemory) { + const strategySummary = this.launchSortieRecommendation + ? `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}` + : (() => { + const order = sortieOrderDefinition(this.launchSortieOrderId); + return `선택 군령 · ${order.label} · ${order.summary}`; + })(); + return `${this.thirdCampPreparationMemory.deploymentLine}\n${strategySummary}`; + } if (this.launchSortieRecommendation) { return `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`; } @@ -9704,6 +9869,11 @@ export class BattleScene extends Phaser.Scene { ( this.secondBattleReliefMemory?.effect.kind === 'ferry-messenger-intel' && this.secondBattleReliefMemory.effect.revealTrackedRoute === true + ) || + ( + this.thirdCampPreparationMemory?.effect.kind === + 'marked-vanguard-intel' && + !this.thirdCampPreparationRuntime.informationConsumed ) ) && !this.battleOutcome @@ -9718,20 +9888,33 @@ export class BattleScene extends Phaser.Scene { } private trackedDeploymentIntentEnemyId() { + if (this.phase !== 'deployment') { + return undefined; + } if ( - this.phase !== 'deployment' || ( this.firstBattlePreparation.eligible && this.firstBattlePreparation.scouting ) || - this.firstPursuitScoutMemory?.deploymentIntentPreview === true || - this.secondBattleReliefMemory?.effect.kind !== - 'ferry-messenger-intel' || - this.secondBattleReliefMemory.effect.revealTrackedRoute !== true + this.firstPursuitScoutMemory?.deploymentIntentPreview === true ) { return undefined; } - return this.secondBattleReliefMemory.effect.trackedEnemyUnitId; + if ( + this.thirdCampPreparationMemory?.effect.kind === + 'marked-vanguard-intel' && + !this.thirdCampPreparationRuntime.informationConsumed + ) { + return this.thirdCampPreparationMemory.effect.trackedEnemyUnitId; + } + if ( + this.secondBattleReliefMemory?.effect.kind === + 'ferry-messenger-intel' && + this.secondBattleReliefMemory.effect.revealTrackedRoute === true + ) { + return this.secondBattleReliefMemory.effect.trackedEnemyUnitId; + } + return undefined; } private enemyIntentForecastVisibleForUnit(unitId: string) { @@ -12502,6 +12685,22 @@ export class BattleScene extends Phaser.Scene { tone: palette.gold }); } + if (preview.preparationEffectLabels.length > 0) { + summaries.push({ + icon: + preview.preparationDamageReduction > 0 + ? 'defense' + : 'focus', + label: + this.thirdCampPreparationMemory?.label ?? + '군영 준비', + value: preview.preparationEffectLabels.join(' / '), + tone: + preview.preparationDamageReduction > 0 + ? palette.green + : 0x83d6ff + }); + } this.unitStatuses(preview.attacker).forEach((status) => { summaries.push({ icon: this.statusEffectIcon(status.kind), @@ -13531,6 +13730,7 @@ export class BattleScene extends Phaser.Scene { this.hideTacticalCommandCutIn(); this.tacticalInitiativeChipObjects.forEach((object) => object.destroy()); this.tacticalInitiativeChipObjects = []; + this.thirdCampPreparationChipBounds = undefined; this.hideBattleAlert(); this.clearBattleEvents(); this.refreshUnitLegibilityStyles(); @@ -14242,14 +14442,37 @@ export class BattleScene extends Phaser.Scene { } private resultSubtitle(outcome: BattleOutcome, rewards: CampaignRewardSnapshot) { - if (outcome !== 'victory') { - return `${battleScenario.title}에서 패배했습니다. 진형과 보급을 정비한 뒤 다시 도전해야 합니다.`; - } + const base = + outcome !== 'victory' + ? `${battleScenario.title}에서 패배했습니다. 진형과 보급을 정비한 뒤 다시 도전해야 합니다.` + : rewards.unlocks[0]?.title + ? `${battleScenario.title} 승리. 보상 정산 후 ${rewards.unlocks[0].title}로 이어집니다.` + : `${battleScenario.title} 승리. 획득 보상과 장수 성장을 정산합니다.`; + const preparation = + this.thirdCampPreparationPresentation('result'); + return preparation + ? `${base}\n군영 준비 결과 · ${this.thirdCampPreparationResultText(preparation)}` + : base; + } - const unlock = rewards.unlocks[0]?.title; - return unlock - ? `${battleScenario.title} 승리. 보상 정산 후 ${unlock}로 이어집니다.` - : `${battleScenario.title} 승리. 획득 보상과 장수 성장을 정산합니다.`; + private thirdCampPreparationResultText( + presentation: + ThirdCampPreparationBattlePresentation + ) { + if ( + presentation.priorityId === 'equipment' && + this.thirdCampPreparationRuntime.usageCount > 0 + ) { + return `${presentation.text} · 실제 피해 ${this.thirdCampPreparationRuntime.preventedDamage} 감소`; + } + if ( + presentation.priorityId === 'companion' && + this.thirdCampPreparationRuntime.companionAttempts > + 0 + ) { + return `${presentation.text} · 협공 ${this.thirdCampPreparationRuntime.companionSuccesses}/${this.thirdCampPreparationRuntime.companionAttempts}회`; + } + return presentation.text; } private resultItemRewards(outcome: BattleOutcome) { @@ -16396,7 +16619,8 @@ export class BattleScene extends Phaser.Scene { usable?: BattleUsable, isCounter = false, ignoreSortieEffects = false, - ignoreTacticalInitiative = false + ignoreTacticalInitiative = false, + ignoreThirdCampPreparation = false ): CombatPreview { const terrain = battleMap.terrain[defender.y][defender.x]; const terrainRule = getTerrainRule(terrain); @@ -16413,6 +16637,14 @@ export class BattleScene extends Phaser.Scene { isCounter, ignoreTacticalInitiative ); + const preparationEffect = + this.thirdCampPreparationCombatEffect( + attacker, + defender, + action, + isCounter, + ignoreThirdCampPreparation + ); const totalDamageBonusWithoutSortie = bondBonus.damageBonus + equipmentEffect.damageBonus - @@ -16471,6 +16703,22 @@ export class BattleScene extends Phaser.Scene { Math.max(1, Math.round(damageBeforeInitiative * (1 - initiativeEffect.damageReduction / 100))) ); } + const damageBeforePreparation = damage; + if ( + preparationEffect.damageReduction > 0 && + damageBeforePreparation > 1 + ) { + damage = Math.min( + damageBeforePreparation - 1, + Math.max( + 1, + Math.round( + damageBeforePreparation * + (1 - preparationEffect.damageReduction / 100) + ) + ) + ); + } const defenderTerrainRating = getUnitClass(defender.classKey).terrainRatings[terrain] ?? 100; const terrainHitPenalty = Math.floor(terrainRule.defenseBonus / 2) + Math.max(0, Math.floor((defenderTerrainRating - 100) / 4)); const hitRate = Phaser.Math.Clamp( @@ -16482,6 +16730,7 @@ export class BattleScene extends Phaser.Scene { equipmentEffect.hitBonus + sortieEffect.hitBonus + initiativeEffect.hitBonus + + preparationEffect.hitBonus + this.hitBuff(attacker), 45, 98 @@ -16510,6 +16759,8 @@ export class BattleScene extends Phaser.Scene { bondPartnerIds: bondBonus.partnerIds, bondExtendedRange: Boolean(bondBonus.extendedRange), bondChainRate: bondChainCandidate?.rate ?? 0, + bondChainPreparationRateBonus: + bondChainCandidate?.preparationRateBonus ?? 0, bondChainDamage: bondChainCandidate?.damage ?? 0, bondChainBondId: bondChainCandidate?.bondId, bondChainPartnerId: bondChainCandidate?.partner.id, @@ -16532,6 +16783,11 @@ export class BattleScene extends Phaser.Scene { initiativeDamageReduction: initiativeEffect.damageReduction, initiativeHitBonus: initiativeEffect.hitBonus, initiativeEffectLabels: initiativeEffect.labels, + preparationHitBonus: preparationEffect.hitBonus, + preparationDamageReduction: + preparationEffect.damageReduction, + preparationEffectLabels: preparationEffect.labels, + damageBeforePreparation, damageBeforeInitiative, hitRate, criticalRate, @@ -16662,11 +16918,14 @@ export class BattleScene extends Phaser.Scene { partnerId: partner.id, partnerName: partner.name, rate: preview.bondChainRate, + preparationRateBonus: + preview.bondChainPreparationRateBonus, expectedDamage: preview.bondChainDamage, succeeded, coreResonance: preview.bondChainCoreResonance }; this.recordSortieCooperationAttempt(attempt.bondId); + this.recordThirdCampPreparationCompanionAttempt(attempt); if (attempt.coreResonance) { this.coreResonancePursuitAttempts += 1; } @@ -16718,12 +16977,50 @@ export class BattleScene extends Phaser.Scene { this.bondChainJudgementLast = undefined; } const preview = this.combatPreview(attacker, defender, action, usable, isCounter); - const sortieOnlyPreview = this.combatPreview(attacker, defender, action, usable, isCounter, false, true); - const baselinePreview = this.combatPreview(attacker, defender, action, usable, isCounter, true, true); + const withoutPreparationPreview = this.combatPreview( + attacker, + defender, + action, + usable, + isCounter, + false, + false, + true + ); + const sortieOnlyPreview = this.combatPreview( + attacker, + defender, + action, + usable, + isCounter, + false, + true, + true + ); + const baselinePreview = this.combatPreview( + attacker, + defender, + action, + usable, + isCounter, + true, + true, + true + ); const previousDefenderHp = defender.hp; const hit = this.debugForceHitEnabled() || this.rollPercent(preview.hitRate); const critical = hit && this.rollPercent(preview.criticalRate); const damage = hit ? Phaser.Math.Clamp(Math.round(preview.damage * (critical ? 1.5 : 1)), 1, defender.hp) : 0; + const withoutPreparationDamage = hit + ? Phaser.Math.Clamp( + Math.round( + withoutPreparationPreview.damage * + (critical ? 1.5 : 1) + ), + 1, + previousDefenderHp + ) + : 0; const sortieOnlyDamage = hit ? Phaser.Math.Clamp(Math.round(sortieOnlyPreview.damage * (critical ? 1.5 : 1)), 1, previousDefenderHp) : 0; @@ -16732,8 +17029,29 @@ export class BattleScene extends Phaser.Scene { : 0; const sortieBonusDamageAmount = sortieOnlyPreview.sortieDamageBonus > 0 ? Math.max(0, sortieOnlyDamage - baselineDamage) : 0; const sortiePreventedDamageAmount = sortieOnlyPreview.sortieDamageReduction > 0 ? Math.max(0, baselineDamage - sortieOnlyDamage) : 0; - const initiativeBonusDamageAmount = preview.initiativeDamageBonus > 0 ? Math.max(0, damage - sortieOnlyDamage) : 0; - const initiativePreventedDamageAmount = preview.initiativeDamageReduction > 0 ? Math.max(0, sortieOnlyDamage - damage) : 0; + const initiativeBonusDamageAmount = + withoutPreparationPreview.initiativeDamageBonus > 0 + ? Math.max( + 0, + withoutPreparationDamage - sortieOnlyDamage + ) + : 0; + const initiativePreventedDamageAmount = + withoutPreparationPreview.initiativeDamageReduction > 0 + ? Math.max( + 0, + sortieOnlyDamage - withoutPreparationDamage + ) + : 0; + const preparationPreventedDamageAmount = + preview.preparationDamageReduction > 0 + ? Math.max(0, withoutPreparationDamage - damage) + : 0; + this.recordThirdCampPreparationCombatUse( + preview, + hit, + preparationPreventedDamageAmount + ); const attackerGrowth = this.awardEquipmentExp(attacker, action === 'attack' ? 'weapon' : 'accessory', this.attackerEquipmentExpGain(attacker, action)); const armorGrowth = this.awardEquipmentExp(defender, 'armor', this.defenderArmorExpGain(defender, hit, preview)); const characterGrowth = this.awardCharacterExp(attacker, this.characterExpGain(preview, damage, hit, critical)); @@ -16813,6 +17131,7 @@ export class BattleScene extends Phaser.Scene { sortiePreventedDamageAmount, initiativeBonusDamageAmount, initiativePreventedDamageAmount, + preparationPreventedDamageAmount, initiativeReadied, bondChainAttempt, bondChain @@ -17096,14 +17415,22 @@ export class BattleScene extends Phaser.Scene { const initiativeContribution = this.combatInitiativeContributionText(result); const summaryInitiative = initiativeContribution ? `\n전술 명령: ${initiativeContribution}` : ''; const summaryEquipment = result.equipmentEffectLabels.length > 0 ? `\n장비 효과: ${result.equipmentEffectLabels.join(', ')}` : ''; + const preparationContribution = + this.combatPreparationContributionText(result); + const summaryPreparation = preparationContribution + ? `\n군영 준비: ${preparationContribution}` + : ''; const summaryBond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : ''; + const bondChainPreparation = result.bondChainAttempt?.preparationRateBonus + ? ` · 군영 +${result.bondChainAttempt.preparationRateBonus}%p` + : ''; const summaryBondChain = result.bondChainAttempt ? result.bondChain - ? `\n${result.bondChainAttempt.coreResonance ? '핵심 공명' : '공명'} 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 일치\n${result.bondChain.coreResonance ? '핵심 공명' : '공명'} 연계 · ${result.bondChain.partnerName} 추격 피해 +${result.bondChain.damage}${result.bondChain.defeated ? ' · 공명 격파' : ''}` - : `\n${result.bondChainAttempt.coreResonance ? '핵심 공명' : '공명'} 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 불발` + ? `\n${result.bondChainAttempt.coreResonance ? '핵심 공명' : '공명'} 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}%${bondChainPreparation} · 호흡 일치\n${result.bondChain.coreResonance ? '핵심 공명' : '공명'} 연계 · ${result.bondChain.partnerName} 추격 피해 +${result.bondChain.damage}${result.bondChain.defeated ? ' · 공명 격파' : ''}` + : `\n${result.bondChainAttempt.coreResonance ? '핵심 공명' : '공명'} 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}%${bondChainPreparation} · 호흡 불발` : ''; const summaryCounter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : ''; - return `${headline}\n${resolution}${summaryBondChain}${summaryCounter}${summaryBondBonus}${summarySortie}${summaryInitiative}${summaryEquipment}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${summaryBond}`; + return `${headline}\n${resolution}${summaryBondChain}${summaryCounter}${summaryBondBonus}${summarySortie}${summaryInitiative}${summaryPreparation}${summaryEquipment}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${summaryBond}`; } @@ -17227,7 +17554,31 @@ export class BattleScene extends Phaser.Scene { return ''; } + private combatPreparationContributionText(result: CombatResult) { + const parts = [...result.preparationEffectLabels]; + if (result.preparationPreventedDamageAmount > 0) { + parts.push( + `실제 피해 ${result.preparationPreventedDamageAmount} 감소` + ); + } + if (result.bondChainAttempt?.preparationRateBonus) { + parts.push( + `동료 공명 +${result.bondChainAttempt.preparationRateBonus}%p` + ); + } + return parts.join(' · '); + } + private combatSortieRibbonText(result: CombatResult) { + if (result.preparationPreventedDamageAmount > 0) { + return ` · 준비 -${result.preparationPreventedDamageAmount}`; + } + if (result.preparationHitBonus > 0) { + return ` · 준비 명중 +${result.preparationHitBonus}%p`; + } + if (result.bondChainAttempt?.preparationRateBonus) { + return ` · 준비 공명 +${result.bondChainAttempt.preparationRateBonus}%p`; + } if (result.initiativePreventedDamageAmount > 0) { return ` · 철벽 -${result.initiativePreventedDamageAmount}`; } @@ -17261,6 +17612,10 @@ export class BattleScene extends Phaser.Scene { if (result.equipmentEffectLabels.length > 0) { summaryLines.push(`장비 효과: ${result.equipmentEffectLabels.join(', ')}`); } + const preparation = this.combatPreparationContributionText(result); + if (preparation) { + summaryLines.push(`군영 준비: ${preparation}`); + } return summaryLines; } @@ -17919,6 +18274,231 @@ export class BattleScene extends Phaser.Scene { ]; } + private resetThirdCampPreparationRuntime() { + this.thirdCampPreparationRuntime = { + informationConsumed: false, + equipmentConsumed: false, + companionAttempts: 0, + companionSuccesses: 0, + usageCount: 0, + preventedDamage: 0 + }; + } + + private thirdCampPreparationActive() { + return Boolean( + this.thirdCampPreparationMemory && + !this.battleOutcome && + this.turnNumber <= thirdCampPreparationActiveThroughTurn + ); + } + + private thirdCampPreparationCombatEffect( + attacker: UnitData, + defender: UnitData, + action: DamageCommand, + isCounter: boolean, + ignored: boolean + ): ThirdCampPreparationCombatEffect { + const inactive: ThirdCampPreparationCombatEffect = { + hitBonus: 0, + damageReduction: 0, + labels: [] + }; + const memory = this.thirdCampPreparationMemory; + if (ignored || !memory || !this.thirdCampPreparationActive()) { + return inactive; + } + + if ( + memory.effect.kind === 'marked-vanguard-intel' && + !this.thirdCampPreparationRuntime.informationConsumed && + !isCounter && + action === 'attack' && + attacker.faction === 'ally' && + defender.faction === 'enemy' && + defender.id === memory.effect.trackedEnemyUnitId + ) { + return { + hitBonus: memory.effect.accuracyBonus, + damageReduction: 0, + labels: [`본영 정보 · 명중 +${memory.effect.accuracyBonus}%p`] + }; + } + + if ( + memory.effect.kind === 'prepared-equipment-guard' && + !this.thirdCampPreparationRuntime.equipmentConsumed && + attacker.faction === 'enemy' && + defender.faction === 'ally' + ) { + return { + hitBonus: 0, + damageReduction: + memory.effect.incomingDamageReductionPercent, + labels: [ + `장비 정비 · 첫 피격 -${memory.effect.incomingDamageReductionPercent}%` + ] + }; + } + + return inactive; + } + + private recordThirdCampPreparationCombatUse( + preview: CombatPreview, + hit: boolean, + preventedDamage: number + ) { + const memory = this.thirdCampPreparationMemory; + if (!memory || !this.thirdCampPreparationActive()) { + return; + } + + if ( + memory.effect.kind === 'marked-vanguard-intel' && + !this.thirdCampPreparationRuntime.informationConsumed && + preview.preparationHitBonus > 0 + ) { + this.thirdCampPreparationRuntime.informationConsumed = true; + this.thirdCampPreparationRuntime.usageCount = 1; + this.renderTacticalInitiativeChip(); + return; + } + + if ( + memory.effect.kind === 'prepared-equipment-guard' && + !this.thirdCampPreparationRuntime.equipmentConsumed && + hit && + preview.preparationDamageReduction > 0 + ) { + this.thirdCampPreparationRuntime.equipmentConsumed = true; + this.thirdCampPreparationRuntime.usageCount = 1; + this.thirdCampPreparationRuntime.preventedDamage += + Math.max(0, preventedDamage); + this.renderTacticalInitiativeChip(); + } + } + + private thirdCampPreparationBondRateBonus( + bond: Pick + ) { + const memory = this.thirdCampPreparationMemory; + if ( + !memory || + memory.effect.kind !== 'return-bond-opening' || + !this.thirdCampPreparationActive() || + bond.id !== memory.effect.bondId || + !memory.effect.unitIds.every((unitId) => + battleUnits.some( + (unit) => + unit.id === unitId && + unit.faction === 'ally' && + unit.hp > 0 + ) + ) + ) { + return 0; + } + return memory.effect.cooperationRateBonus; + } + + private recordThirdCampPreparationCompanionAttempt( + attempt: BondChainAttempt + ) { + const memory = this.thirdCampPreparationMemory; + if ( + !memory || + memory.effect.kind !== 'return-bond-opening' || + !this.thirdCampPreparationActive() || + attempt.bondId !== memory.effect.bondId + ) { + return; + } + this.thirdCampPreparationRuntime.companionAttempts += 1; + if (attempt.succeeded) { + this.thirdCampPreparationRuntime.companionSuccesses += 1; + } + this.thirdCampPreparationRuntime.usageCount = + this.thirdCampPreparationRuntime.companionAttempts; + this.renderTacticalInitiativeChip(); + } + + private thirdCampPreparationPresentation( + stage: 'deployment' | 'turn' | 'result' + ): ThirdCampPreparationBattlePresentation | undefined { + const memory = this.thirdCampPreparationMemory; + if (!memory) { + return undefined; + } + return resolveThirdCampPreparationBattlePresentation({ + memory, + stage, + ...(stage === 'turn' ? { turnNumber: this.turnNumber } : {}), + usageCount: this.thirdCampPreparationRuntime.usageCount + }); + } + + private thirdCampPreparationSaveState(): + BattleSaveThirdCampPreparationState | undefined { + const memory = this.thirdCampPreparationMemory; + if (!memory) { + return undefined; + } + return { + selectionRecordId: thirdCampPreparationSelectionRecordId, + priorityId: memory.priorityId, + label: memory.label, + ...(memory.effect.kind === 'marked-vanguard-intel' + ? { markedEnemyUnitId: memory.effect.trackedEnemyUnitId } + : {}), + ...this.thirdCampPreparationRuntime + }; + } + + private restoreThirdCampPreparationRuntime( + state?: BattleSaveThirdCampPreparationState + ) { + this.resetThirdCampPreparationRuntime(); + const memory = this.thirdCampPreparationMemory; + if ( + !memory || + !state || + state.selectionRecordId !== memory.selectionRecordId || + state.priorityId !== memory.priorityId || + state.label !== memory.label + ) { + return; + } + if ( + memory.effect.kind === 'marked-vanguard-intel' && + state.markedEnemyUnitId !== memory.effect.trackedEnemyUnitId + ) { + return; + } + this.thirdCampPreparationRuntime = { + informationConsumed: + memory.priorityId === 'information' && + state.informationConsumed, + equipmentConsumed: + memory.priorityId === 'equipment' && + state.equipmentConsumed, + companionAttempts: + memory.priorityId === 'companion' + ? state.companionAttempts + : 0, + companionSuccesses: + memory.priorityId === 'companion' + ? state.companionSuccesses + : 0, + usageCount: state.usageCount, + preventedDamage: + memory.priorityId === 'equipment' + ? state.preventedDamage + : 0 + }; + } + private showOpeningBattleEvent() { const guide = battleScenario.tacticalGuide; const objectiveLines = guide ? [guide.summary, `진군: ${guide.route}`, guide.focus] : battleScenario.openingObjectiveLines; @@ -17934,6 +18514,12 @@ export class BattleScene extends Phaser.Scene { : []), ...(this.firstPursuitScoutMemory?.openingLines ?? []), ...(this.secondBattleReliefMemory?.openingLines ?? []), + ...(this.thirdCampPreparationMemory + ? [ + `군영 선택 · ${this.thirdCampPreparationMemory.label}`, + this.thirdCampPreparationMemory.activeLine + ] + : []), ...cityInformationLines, enemyIntentOpeningGuide(this.isFirstPursuitRoleEffectBattle() ? tacticalInitiativeThreshold : undefined), ...(battleScenario.id === 'first-battle-zhuo-commandery' @@ -18250,10 +18836,19 @@ export class BattleScene extends Phaser.Scene { const completedCityInformationLines = new Set(this.completedCityInformationBriefingLines()); const firstPursuitScoutLines = new Set(this.firstPursuitScoutMemory?.openingLines ?? []); const secondBattleReliefLines = new Set(this.secondBattleReliefMemory?.openingLines ?? []); + const thirdCampPreparationLines = new Set( + this.thirdCampPreparationMemory + ? [ + `군영 선택 · ${this.thirdCampPreparationMemory.label}`, + this.thirdCampPreparationMemory.activeLine + ] + : [] + ); const doctrineLines = lines.filter((line) => ( completedCityInformationLines.has(line) || firstPursuitScoutLines.has(line) || secondBattleReliefLines.has(line) || + thirdCampPreparationLines.has(line) || line.startsWith('도원 공명') || line.startsWith('핵심 공명') || line.startsWith('공명 ') || @@ -18480,6 +19075,16 @@ export class BattleScene extends Phaser.Scene { if (battleScenario.id === 'third-battle-guangzong-road') { return ['guangzong-scout-a', 'guangzong-bandit-a', 'guangzong-archer-a']; } + if ( + battleScenario.id === + 'fourth-battle-guangzong-camp' + ) { + return [ + 'guangzong-main-vanguard-a', + 'guangzong-main-vanguard-b', + 'guangzong-main-bandit-a' + ]; + } return ['rebel-a', 'rebel-b', 'rebel-c']; } @@ -19344,6 +19949,7 @@ export class BattleScene extends Phaser.Scene { ? { ...this.coreResonancePursuitStatsSnapshot() } : undefined, cooperationStatsByBond: this.sortieCooperationStatsRecord(), + thirdCampPreparation: this.thirdCampPreparationSaveState(), savedAt: new Date().toISOString(), turnNumber: this.turnNumber, activeFaction: this.activeFaction, @@ -19493,6 +20099,13 @@ export class BattleScene extends Phaser.Scene { campaign: getCampaignState(), battleId: battleScenario.id }); + this.thirdCampPreparationMemory = resolveThirdCampPreparationMemory({ + campaign: getCampaignState(), + battleId: battleScenario.id + }); + this.restoreThirdCampPreparationRuntime( + state.thirdCampPreparation + ); this.refreshLaunchCamaraderieMemoryBonus(getCampaignState()); if (this.launchSortieResonanceBondId) { this.restoreCoreResonancePursuitStats(state.coreResonanceStats); @@ -21086,10 +21699,24 @@ export class BattleScene extends Phaser.Scene { if (result.bondChainAttempt) { return { icon: 'leadership', - label: `${result.bondChainAttempt.coreResonance ? '핵심 ' : ''}판정 ${result.bondChainAttempt.rate}%`, + label: `${result.bondChainAttempt.coreResonance ? '핵심 ' : ''}판정 ${result.bondChainAttempt.rate}%${result.bondChainAttempt.preparationRateBonus > 0 ? ` · 준비 +${result.bondChainAttempt.preparationRateBonus}` : ''}`, tone: palette.gold }; } + if (result.preparationPreventedDamageAmount > 0) { + return { + icon: 'defense', + label: `준비 -${result.preparationPreventedDamageAmount}`, + tone: palette.green + }; + } + if (result.preparationHitBonus > 0) { + return { + icon: 'focus', + label: `준비 명중 +${result.preparationHitBonus}`, + tone: 0x83d6ff + }; + } if (this.combatCounterWillTrigger(result)) { return { icon: 'counter', label: '반격 예정', tone: 0xb64a45 }; } @@ -24292,10 +24919,17 @@ export class BattleScene extends Phaser.Scene { private bondChainRateBreakdown(bond: Pick) { const baseRate = this.baseBondChainRateForBond(bond); const memoryBonus = this.camaraderieMemoryBonusForBond(bond); + const preparationBonus = + this.thirdCampPreparationBondRateBonus(bond); return { baseRate, memoryBonus, - effectiveRate: Phaser.Math.Clamp(baseRate + memoryBonus, 0, 100) + preparationBonus, + effectiveRate: Phaser.Math.Clamp( + baseRate + memoryBonus + preparationBonus, + 0, + 100 + ) }; } @@ -24367,11 +25001,14 @@ export class BattleScene extends Phaser.Scene { } const [first, second] = strongest.bond.unitIds.map((id) => this.unitName(id)); + const rate = + this.bondChainRateBreakdown(strongest.bond); return { bondId: strongest.bond.id, label: `${strongest.coreResonance ? '핵심 공명 · ' : ''}${strongest.bond.title}: ${first}·${second}`, partner: strongest.partner, - rate: this.bondChainRateForBond(strongest.bond), + rate: rate.effectiveRate, + preparationRateBonus: rate.preparationBonus, damage: this.bondChainSupportDamage(strongest.partner, defender), coreResonance: strongest.coreResonance } satisfies BondChainCandidate; @@ -28631,7 +29268,12 @@ export class BattleScene extends Phaser.Scene { const bond = selectedBondId ? this.bondStates.get(selectedBondId) : undefined; const rate = bond ? this.bondChainRateBreakdown(bond) - : { baseRate: 0, memoryBonus: 0, effectiveRate: 0 }; + : { + baseRate: 0, + memoryBonus: 0, + preparationBonus: 0, + effectiveRate: 0 + }; const cooperationStatsByBond = this.sortieCooperationStatsRecord(); const cooperationSnapshot = this.dominantSortieCooperationSnapshot(); const active = Boolean( @@ -28654,6 +29296,7 @@ export class BattleScene extends Phaser.Scene { rate: rate.effectiveRate, baseRate: rate.baseRate, memoryBonus: rate.memoryBonus, + preparationBonus: rate.preparationBonus, effectiveRate: rate.effectiveRate, stats: { attempts: this.coreResonancePursuitAttempts, @@ -29160,6 +29803,260 @@ export class BattleScene extends Phaser.Scene { }; } + private thirdCampPreparationMemoryDebugState() { + const memory = this.thirdCampPreparationMemory; + if (!memory) { + return { + active: false, + effectActive: false, + sourceBattleId: + thirdCampPreparationSourceBattleId, + targetBattleId: + thirdCampPreparationTargetBattleId, + selectionRecordId: + thirdCampPreparationSelectionRecordId, + priorityId: null, + label: null, + effectKind: null, + activeThroughTurn: + thirdCampPreparationActiveThroughTurn, + evidence: null, + trackedEnemyUnitId: null, + bondId: null, + unitIds: [], + presentation: { + deployment: null, + turn: null, + result: null + }, + resultFeedbackText: null, + runtime: { + ...this.thirdCampPreparationRuntime + }, + chip: { + visible: false, + text: null, + bounds: null + }, + chipBounds: null, + deploymentIntent: { + enabled: false, + visible: false, + enemyId: null, + forecast: null + }, + mechanicsProbe: null, + saveSnapshot: null + }; + } + + const trackedEnemyUnitId = + memory.effect.kind === 'marked-vanguard-intel' + ? memory.effect.trackedEnemyUnitId + : null; + const bondId = + memory.effect.kind === 'return-bond-opening' + ? memory.effect.bondId + : null; + const unitIds = + memory.effect.kind === 'return-bond-opening' + ? [...memory.effect.unitIds] + : []; + const trackedForecast = trackedEnemyUnitId + ? this.enemyIntentForecasts.get(trackedEnemyUnitId) + : undefined; + const deploymentPresentation = + this.thirdCampPreparationPresentation('deployment'); + const turnPresentation = + this.thirdCampPreparationPresentation('turn'); + const resultPresentation = + this.thirdCampPreparationPresentation('result'); + const saveSnapshot = + this.thirdCampPreparationSaveState(); + const mechanicsProbe = (() => { + if (memory.effect.kind === 'marked-vanguard-intel') { + const effect = memory.effect; + const attacker = battleUnits.find( + (unit) => + unit.faction === 'ally' && unit.hp > 0 + ); + const defender = battleUnits.find( + (unit) => + unit.id === effect.trackedEnemyUnitId && + unit.hp > 0 + ); + if (!attacker || !defender) { + return null; + } + const prepared = this.combatPreview( + attacker, + defender, + 'attack' + ); + const baseline = this.combatPreview( + attacker, + defender, + 'attack', + undefined, + false, + false, + false, + true + ); + return { + kind: memory.effect.kind, + attackerId: attacker.id, + defenderId: defender.id, + hitRate: prepared.hitRate, + baselineHitRate: baseline.hitRate, + appliedHitBonus: + prepared.hitRate - baseline.hitRate, + configuredHitBonus: + prepared.preparationHitBonus, + expectedHitBonus: + effect.accuracyBonus + }; + } + if (memory.effect.kind === 'prepared-equipment-guard') { + const effect = memory.effect; + const attacker = battleUnits.find( + (unit) => + unit.faction === 'enemy' && unit.hp > 0 + ); + const defender = battleUnits.find( + (unit) => + unit.faction === 'ally' && unit.hp > 0 + ); + if (!attacker || !defender) { + return null; + } + const prepared = this.combatPreview( + attacker, + defender, + 'attack' + ); + const baseline = this.combatPreview( + attacker, + defender, + 'attack', + undefined, + false, + false, + false, + true + ); + return { + kind: memory.effect.kind, + attackerId: attacker.id, + defenderId: defender.id, + damage: prepared.damage, + baselineDamage: baseline.damage, + preventedDamage: + baseline.damage - prepared.damage, + configuredReductionPercent: + prepared.preparationDamageReduction, + expectedReductionPercent: + effect.incomingDamageReductionPercent + }; + } + const effect = memory.effect; + const bond = this.bondStates.get( + effect.bondId + ); + const rate = bond + ? this.bondChainRateBreakdown(bond) + : undefined; + return { + kind: effect.kind, + bondId: effect.bondId, + unitIds: [...effect.unitIds], + bondAvailable: Boolean(bond), + baseRate: rate?.baseRate ?? 0, + memoryBonus: rate?.memoryBonus ?? 0, + preparationBonus: + rate?.preparationBonus ?? 0, + effectiveRate: rate?.effectiveRate ?? 0, + expectedPreparationBonus: + effect.cooperationRateBonus + }; + })(); + + return { + active: true, + effectActive: this.thirdCampPreparationActive(), + sourceBattleId: memory.sourceBattleId, + targetBattleId: memory.targetBattleId, + selectionRecordId: memory.selectionRecordId, + priorityId: memory.priorityId, + label: memory.label, + effectKind: memory.effect.kind, + activeThroughTurn: + thirdCampPreparationActiveThroughTurn, + evidence: + memory.evidence.kind === + 'priority-return-dialogue' + ? { + ...memory.evidence, + unitIds: [...memory.evidence.unitIds] + } + : { ...memory.evidence }, + trackedEnemyUnitId, + bondId, + unitIds, + presentation: { + deployment: deploymentPresentation + ? { ...deploymentPresentation } + : null, + turn: turnPresentation + ? { ...turnPresentation } + : null, + result: resultPresentation + ? { ...resultPresentation } + : null + }, + resultFeedbackText: resultPresentation + ? this.thirdCampPreparationResultText( + resultPresentation + ) + : null, + runtime: { + ...this.thirdCampPreparationRuntime + }, + chip: { + visible: Boolean( + this.thirdCampPreparationChipBounds + ), + text: this.thirdCampPreparationChipBounds + ? this.thirdCampPreparationChipLabel() ?? + null + : null, + bounds: this.thirdCampPreparationChipBounds + ? { ...this.thirdCampPreparationChipBounds } + : null + }, + chipBounds: this.thirdCampPreparationChipBounds + ? { ...this.thirdCampPreparationChipBounds } + : null, + deploymentIntent: { + enabled: Boolean(trackedEnemyUnitId), + visible: Boolean( + trackedEnemyUnitId && + this.phase === 'deployment' && + this.enemyIntentForecastEnabled() && + trackedForecast + ), + enemyId: trackedEnemyUnitId, + forecast: trackedForecast + ? this.enemyActionPlanDebugValue(trackedForecast) + : null + }, + mechanicsProbe, + saveSnapshot: saveSnapshot + ? { ...saveSnapshot } + : null + }; + } + private firstBattleVolunteerPromiseDebugState() { const line = volunteerPromiseLineForBattle( battleScenario.id, @@ -29311,6 +30208,8 @@ export class BattleScene extends Phaser.Scene { firstBattlePreparation: this.firstBattlePreparationDebugState(), firstPursuitScoutMemory: this.firstPursuitScoutMemoryDebugState(), secondBattleReliefMemory: this.secondBattleReliefMemoryDebugState(), + thirdCampPreparation: + this.thirdCampPreparationMemoryDebugState(), firstBattleNarrativeMemory: { volunteerPromise: this.firstBattleVolunteerPromiseDebugState() }, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 1b0903e..e73aa17 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -49,6 +49,12 @@ import { thirdBattleReturnCampStep, thirdBattleReturnSourceBattleId } from '../data/thirdBattleReturnDialogue'; +import { + thirdCampPreparationSourceBattleId, + thirdCampPreparationTargetBattleId, + type ThirdCampPreparationPriorityId, + type ThirdCampPreparationUnavailableReason +} from '../data/thirdCampPreparation'; import { findCityStayAfterBattle, type CityEquipmentOffer, @@ -242,6 +248,10 @@ import { purchaseCityStayEquipment } from '../state/cityStayActions'; import { secondBattleReliefProgress } from '../state/secondBattleReliefActions'; +import { + setThirdCampPreparationPriority, + thirdCampPreparationProgress +} from '../state/thirdCampPreparationActions'; import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { palette } from '../ui/palette'; @@ -412,6 +422,15 @@ type SortiePortraitRosterLayout = { nextEnabled: boolean; }; +type ThirdCampPreparationCardView = { + priorityId: ThirdCampPreparationPriorityId; + selectable: boolean; + selected: boolean; + unavailableReason?: ThirdCampPreparationUnavailableReason; + background: Phaser.GameObjects.Rectangle; + actionButton: Phaser.GameObjects.Rectangle; +}; + type CampSupplyId = 'bean' | 'wine' | 'salve'; type CampSupplyDefinition = { @@ -11475,6 +11494,10 @@ export class CampScene extends Phaser.Scene { private firstBattleFollowupCtaButton?: Phaser.GameObjects.Rectangle; private firstBattleFollowupCard?: Phaser.GameObjects.Rectangle; private firstBattleFollowupSummaryText?: Phaser.GameObjects.Text; + private thirdCampPreparationBrowserOpen = false; + private thirdCampPreparationToggleButton?: Phaser.GameObjects.Rectangle; + private thirdCampPreparationCloseButton?: Phaser.GameObjects.Rectangle; + private thirdCampPreparationCardViews: ThirdCampPreparationCardView[] = []; private visitedTabs = new Set(); private selectedSortieUnitIds: string[] = []; private sortieFormationAssignments: SortieFormationAssignments = {}; @@ -11526,6 +11549,7 @@ export class CampScene extends Phaser.Scene { init(data?: CampSceneData) { this.ownedCampTextureKeys.clear(); + this.thirdCampPreparationBrowserOpen = false; if (data?.completedAftermathBattleId) { completeCampaignAftermath(data.completedAftermathBattleId); } @@ -13889,6 +13913,13 @@ export class CampScene extends Phaser.Scene { return; } + if (this.sortieObjects.length > 0 && this.thirdCampPreparationBrowserOpen) { + soundDirector.playSelect(); + this.thirdCampPreparationBrowserOpen = false; + this.showSortiePrep(); + return; + } + if (this.sortieObjects.length > 0 && this.sortieFormationPanelMode !== 'roster') { soundDirector.playSelect(); this.closeSortieFormationPanelBrowser(); @@ -13912,6 +13943,7 @@ export class CampScene extends Phaser.Scene { this.equipmentSwapConfirmObjects.length > 0 || this.saveSlotObjects.length > 0 || this.saveSlotConfirmObjects.length > 0 || + this.thirdCampPreparationBrowserOpen || !this.usesStagedSortiePrep() ) { return; @@ -14248,6 +14280,13 @@ export class CampScene extends Phaser.Scene { return flow.afterBattleId === campBattleIds.first && flow.nextBattleId === campBattleIds.second; } + private isThirdCampPreparationSlice(flow = this.currentSortieFlow()) { + return ( + flow.afterBattleId === thirdCampPreparationSourceBattleId && + flow.nextBattleId === thirdCampPreparationTargetBattleId + ); + } + private usesStagedSortiePrep(flow = this.currentSortieFlow()) { return Boolean(flow.nextBattleId); } @@ -14355,8 +14394,16 @@ export class CampScene extends Phaser.Scene { } private renderFirstSortieTacticalNotes(x: number, y: number, width: number, height: number, depth: number) { + if (this.isThirdCampPreparationSlice() && this.thirdCampPreparationBrowserOpen) { + this.renderThirdCampPreparationBrowser(x, y, width, height, depth); + return; + } + const scenario = this.nextSortieScenario(); const firstSortie = this.isFirstSortiePrepSlice(); + const thirdCampPreparation = this.isThirdCampPreparationSlice() && this.campaign + ? thirdCampPreparationProgress(this.campaign) + : undefined; const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94)); bg.setOrigin(0); bg.setDepth(depth); @@ -14412,26 +14459,46 @@ export class CampScene extends Phaser.Scene { if (firstBattleFollowup) { this.firstBattleFollowupCard = command; } - const commandTitle = firstBattleFollowup - ? `지난 전투 인계 · ${firstBattleFollowup.mentorName}` - : '지휘관 판단'; + const commandTitle = thirdCampPreparation + ? thirdCampPreparation.ready + ? `출진 우선 · ${thirdCampPreparation.selectedLabel}` + : '출진 우선 · 하나를 선택' + : firstBattleFollowup + ? `지난 전투 인계 · ${firstBattleFollowup.mentorName}` + : '지휘관 판단'; this.trackSortie(this.add.text(x + 34, commandY + 12, commandTitle, this.textStyle(14, '#ffdf7b', true))).setDepth(depth + 2); - const commandSummary = firstBattleFollowup - ? firstBattleFollowup.achieved - ? `${firstBattleFollowup.originalOrderLabel} 완수 → ${firstBattleFollowup.recommendedNextOrderLabel} · ${firstBattleFollowup.focusCriterionLabel}` - : `${firstBattleFollowup.originalOrderLabel} 보완 → ${firstBattleFollowup.focusCriterionLabel}` - : scenario.tacticalGuide?.summary ?? scenario.openingObjectiveLines[1] ?? (firstSortie - ? '세 형제가 서로 다른 길을 맡아 적의 전열을 흔드십시오.' - : '전열·돌파·후원의 균형으로 적의 주력을 나누십시오.'); + const selectedPreparation = thirdCampPreparation?.availability.find( + (entry) => entry.priority.id === thirdCampPreparation.selectedPriorityId + ); + const commandSummary = thirdCampPreparation + ? selectedPreparation?.priority.summary ?? + '실제로 확인한 정보·장비·동료 활동 가운데 이번 출진에 먼저 반영할 하나를 정하십시오.' + : firstBattleFollowup + ? firstBattleFollowup.achieved + ? `${firstBattleFollowup.originalOrderLabel} 완수 → ${firstBattleFollowup.recommendedNextOrderLabel} · ${firstBattleFollowup.focusCriterionLabel}` + : `${firstBattleFollowup.originalOrderLabel} 보완 → ${firstBattleFollowup.focusCriterionLabel}` + : scenario.tacticalGuide?.summary ?? scenario.openingObjectiveLines[1] ?? (firstSortie + ? '세 형제가 서로 다른 길을 맡아 적의 전열을 흔드십시오.' + : '전열·돌파·후원의 균형으로 적의 주력을 나누십시오.'); const commandSummaryText = this.trackSortie( this.add.text(x + 34, commandY + 36, commandSummary, { - ...this.textStyle(firstBattleFollowup ? 11 : 13, '#d4dce6'), - wordWrap: { width: firstBattleFollowup ? width - 214 : width - 68, useAdvancedWrap: true }, - lineSpacing: firstBattleFollowup ? 2 : 3 + ...this.textStyle(firstBattleFollowup || thirdCampPreparation ? 11 : 13, '#d4dce6'), + wordWrap: { + width: firstBattleFollowup || thirdCampPreparation ? width - 214 : width - 68, + useAdvancedWrap: true + }, + lineSpacing: firstBattleFollowup || thirdCampPreparation ? 2 : 3 }) ); commandSummaryText.setDepth(depth + 2); - if (firstBattleFollowup) { + if (thirdCampPreparation) { + this.renderThirdCampPreparationToggle( + x + width - 86, + commandY + 51, + thirdCampPreparation.ready ? '준비 변경' : '준비 선택', + depth + 2 + ); + } else if (firstBattleFollowup) { this.firstBattleFollowupSummaryText = commandSummaryText; } if (firstBattleFollowup) { @@ -14463,6 +14530,320 @@ export class CampScene extends Phaser.Scene { } } + private renderThirdCampPreparationToggle( + x: number, + y: number, + label: string, + depth: number + ) { + const width = 132; + const button = this.trackSortie( + this.add.rectangle(x, y, width, 34, 0x4a371d, 0.98) + ); + button.setDepth(depth); + button.setStrokeStyle(2, palette.gold, 0.92); + button.setInteractive({ useHandCursor: true }); + const open = () => { + this.thirdCampPreparationBrowserOpen = true; + soundDirector.playSelect(); + this.showSortiePrep(); + }; + button.on('pointerover', () => button.setFillStyle(0x654c25, 1)); + button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98)); + button.on('pointerdown', open); + this.thirdCampPreparationToggleButton = button; + + const text = this.trackSortie( + this.add.text(x, y, label, this.textStyle(11, '#fff2b8', true)) + ); + text.setOrigin(0.5); + text.setDepth(depth + 1); + text.setInteractive({ useHandCursor: true }); + text.on('pointerover', () => button.setFillStyle(0x654c25, 1)); + text.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98)); + text.on('pointerdown', open); + } + + private renderThirdCampPreparationBrowser( + x: number, + y: number, + width: number, + height: number, + depth: number + ) { + const progress = this.campaign + ? thirdCampPreparationProgress(this.campaign) + : undefined; + const background = this.trackSortie( + this.add.rectangle(x, y, width, height, 0x0d141c, 0.98) + ); + background.setOrigin(0); + background.setDepth(depth); + background.setStrokeStyle(1, palette.gold, 0.72); + + this.trackSortie( + 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턴에 반영합니다.', + this.textStyle(10, '#9fb0bf') + ) + ).setDepth(depth + 1); + + const close = this.trackSortie( + this.add.rectangle(x + width - 29, y + 25, 38, 26, 0x1a2630, 0.96) + ); + close.setDepth(depth + 1); + close.setStrokeStyle(1, palette.blue, 0.68); + close.setInteractive({ useHandCursor: true }); + const closeBrowser = () => { + this.thirdCampPreparationBrowserOpen = false; + soundDirector.playSelect(); + this.showSortiePrep(); + }; + close.on('pointerover', () => close.setFillStyle(0x283947, 1)); + close.on('pointerout', () => close.setFillStyle(0x1a2630, 0.96)); + close.on('pointerdown', closeBrowser); + this.thirdCampPreparationCloseButton = close; + const closeLabel = this.trackSortie( + this.add.text(x + width - 29, y + 25, '×', this.textStyle(17, '#f2e3bf', true)) + ); + closeLabel.setOrigin(0.5); + closeLabel.setDepth(depth + 2); + closeLabel.setInteractive({ useHandCursor: true }); + closeLabel.on('pointerdown', closeBrowser); + + if (!progress) { + this.trackSortie( + this.add.text( + x + width / 2, + y + height / 2, + '현재 출진에서는 우선 준비를 선택할 수 없습니다.', + this.textStyle(12, '#9fb0bf', true) + ) + ).setOrigin(0.5).setDepth(depth + 1); + return; + } + + const sealByPriority: Record = { + information: '보', + equipment: '장', + companion: '동' + }; + const toneByPriority: Record = { + information: palette.blue, + equipment: palette.gold, + companion: 0xc887dc + }; + progress.availability.forEach((entry, index) => { + const priorityId = entry.priority.id; + const selected = progress.selectedPriorityId === priorityId; + const cardY = y + 68 + index * 106; + const cardHeight = 98; + const fill = selected + ? 0x183126 + : entry.selectable + ? 0x17232e + : 0x111820; + const tone = selected ? palette.green : toneByPriority[priorityId]; + const card = this.trackSortie( + this.add.rectangle(x + 16, cardY, width - 32, cardHeight, fill, entry.selectable ? 0.98 : 0.78) + ); + card.setOrigin(0); + card.setDepth(depth + 1); + card.setStrokeStyle(selected ? 2 : 1, tone, selected ? 0.96 : entry.selectable ? 0.68 : 0.28); + + const seal = this.trackSortie( + this.add.circle(x + 43, cardY + 31, 17, tone, entry.selectable ? 0.9 : 0.38) + ); + seal.setDepth(depth + 2); + seal.setStrokeStyle(1, selected ? 0xa8ffd0 : 0xf2e3bf, entry.selectable ? 0.72 : 0.24); + this.trackSortie( + this.add.text(x + 43, cardY + 31, sealByPriority[priorityId], this.textStyle(13, '#f2e3bf', true)) + ).setOrigin(0.5).setDepth(depth + 3); + this.trackSortie( + this.add.text( + x + 68, + cardY + 10, + `${entry.priority.label} · ${entry.priority.activityLabel}`, + this.textStyle(13, entry.selectable ? '#ffdf7b' : '#77818c', true) + ) + ).setDepth(depth + 2); + this.trackSortie( + this.add.text( + x + 68, + cardY + 33, + entry.selectable + ? entry.priority.summary + : this.thirdCampPreparationUnavailableLabel(entry.unavailableReason), + { + ...this.textStyle(10, entry.selectable ? '#d4dce6' : '#7f8994'), + wordWrap: { width: width - 172, useAdvancedWrap: true }, + lineSpacing: 2, + maxLines: 3 + } + ) + ).setDepth(depth + 2); + + const actionX = x + width - 57; + const actionY = cardY + cardHeight / 2; + const action = this.trackSortie( + this.add.rectangle( + actionX, + actionY, + 82, + 32, + selected ? 0x28533c : entry.selectable ? 0x4a371d : 0x1a2630, + entry.selectable ? 0.98 : 0.8 + ) + ); + action.setDepth(depth + 2); + action.setStrokeStyle(1, tone, entry.selectable ? 0.82 : 0.34); + action.setInteractive({ useHandCursor: true }); + const actionLabel = this.trackSortie( + this.add.text( + actionX, + actionY, + selected + ? '선택됨' + : entry.selectable + ? '선택' + : this.thirdCampPreparationLockedActionLabel(priorityId), + this.textStyle(10, entry.selectable ? '#fff2b8' : '#9fb0bf', true) + ) + ); + actionLabel.setOrigin(0.5); + actionLabel.setDepth(depth + 3); + actionLabel.setInteractive({ useHandCursor: true }); + + const run = () => { + if (entry.selectable) { + this.selectThirdCampPreparationPriority(priorityId); + return; + } + this.openThirdCampPreparationActivity(priorityId); + }; + [card, action, actionLabel].forEach((target) => { + target.setInteractive({ useHandCursor: true }); + target.on('pointerdown', run); + }); + action.on('pointerover', () => + action.setFillStyle(entry.selectable ? 0x654c25 : 0x283947, 1) + ); + action.on('pointerout', () => + action.setFillStyle( + selected ? 0x28533c : entry.selectable ? 0x4a371d : 0x1a2630, + entry.selectable ? 0.98 : 0.8 + ) + ); + this.thirdCampPreparationCardViews.push({ + priorityId, + selectable: entry.selectable, + selected, + ...(entry.unavailableReason + ? { unavailableReason: entry.unavailableReason } + : {}), + background: card, + actionButton: action + }); + }); + + this.trackSortie( + this.add.text( + x + 18, + y + height - 25, + '권장 선택 · 출진 전에는 언제든 다른 완료 활동으로 바꿀 수 있습니다.', + this.textStyle(10, progress.ready ? '#a8ffd0' : '#ffdf7b', true) + ) + ).setDepth(depth + 1); + } + + private thirdCampPreparationUnavailableLabel( + reason?: ThirdCampPreparationUnavailableReason + ) { + if (reason === 'report-review-required') { + return '승전 보고에서 광종 본영 진입 정보를 먼저 확인해야 합니다.'; + } + if (reason === 'equipment-change-required') { + return '3차 승리로 받은 장비를 장비 탭에서 먼저 확인해야 합니다.'; + } + if (reason === 'priority-dialogue-required') { + return '전투 결과에 맞춰 표시된 우선 귀환 대화를 먼저 마쳐야 합니다.'; + } + return '광종 구원로 승리 기록이 있어야 선택할 수 있습니다.'; + } + + private thirdCampPreparationLockedActionLabel( + priorityId: ThirdCampPreparationPriorityId + ) { + if (priorityId === 'equipment') { + return '장비 보기'; + } + if (priorityId === 'companion') { + return '대화하기'; + } + return '보고 확인'; + } + + private selectThirdCampPreparationPriority( + priorityId: ThirdCampPreparationPriorityId + ) { + const result = setThirdCampPreparationPriority(priorityId); + if (!result.ok) { + const message = result.reason === 'activity-required' + ? '관련 활동을 먼저 마쳐야 이 준비를 선택할 수 있습니다.' + : '현재 진행에서는 이 준비를 저장할 수 없습니다.'; + this.showCampNotice(message); + return; + } + + this.campaign = result.campaign; + this.thirdCampPreparationBrowserOpen = true; + soundDirector.playSelect(); + this.showCampNotice( + `${result.priority.label} 선택 · ${result.memory.activeLine}` + ); + this.showSortiePrep(); + } + + private openThirdCampPreparationActivity( + priorityId: ThirdCampPreparationPriorityId + ) { + if (priorityId === 'equipment') { + this.thirdCampPreparationBrowserOpen = false; + this.hideSortiePrep(); + this.activeTab = 'equipment'; + this.render(); + this.showCampNotice('3차 승전 장비를 확인했습니다. 출진 준비에서 장비 정비를 선택할 수 있습니다.'); + 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.thirdCampPreparationBrowserOpen = true; + soundDirector.playSelect(); + this.showCampNotice('승전 보고의 광종 본영 진입 정보를 확인했습니다.'); + this.showSortiePrep(); + } + private openFirstBattleFollowupOrderBrowser() { if (!this.firstBattleCampFollowup() || !this.nextSortieScenario()) { return; @@ -20211,6 +20592,9 @@ export class CampScene extends Phaser.Scene { const roleDetail = hasBattle ? `전열 ${roleCounts.front} · 돌파 ${roleCounts.flank} · 후원 ${roleCounts.support}` : `대표 ${roleCounts.front} · 현장 ${roleCounts.flank} · 보좌 ${roleCounts.support}`; + const thirdCampPreparation = this.isThirdCampPreparationSlice() && this.campaign + ? thirdCampPreparationProgress(this.campaign) + : undefined; return [ { label: requiredLabel, @@ -20218,6 +20602,16 @@ export class CampScene extends Phaser.Scene { detail: requiredDetail, priority: 'required' }, + ...(thirdCampPreparation + ? [{ + label: '출진 우선 준비', + complete: thirdCampPreparation.ready, + detail: thirdCampPreparation.ready + ? `${thirdCampPreparation.selectedLabel} · 첫 3턴 반영` + : '정보·장비·동료 중 완료한 활동 하나 선택', + priority: 'recommended' as const + }] + : []), { label: '부상 장수 확인', complete: recoveryTargets.length === 0, @@ -20587,6 +20981,9 @@ export class CampScene extends Phaser.Scene { this.firstBattleFollowupCtaButton = undefined; this.firstBattleFollowupCard = undefined; this.firstBattleFollowupSummaryText = undefined; + this.thirdCampPreparationToggleButton = undefined; + this.thirdCampPreparationCloseButton = undefined; + this.thirdCampPreparationCardViews = []; this.sortiePortraitRosterLayout = undefined; this.sortieRosterToggleBounds = []; this.sortieHoveredUnitId = undefined; @@ -20597,6 +20994,7 @@ export class CampScene extends Phaser.Scene { this.sortieFormationPanelMode = 'roster'; this.sortieCoreResonanceSelectorOpen = false; this.sortieCoreResonancePage = 0; + this.thirdCampPreparationBrowserOpen = false; } this.sortieComparisonPanelView = undefined; this.sortiePursuitPanelView = undefined; @@ -25285,6 +25683,9 @@ export class CampScene extends Phaser.Scene { : undefined; const secondReliefChoiceId = this.campaign?.campVisitChoiceIds[secondBattleReliefVisitId] ?? null; + const thirdCampPreparation = this.campaign && this.isThirdCampPreparationSlice() + ? thirdCampPreparationProgress(this.campaign) + : undefined; const availableCampDialogues = this.availableCampDialogues(); const selectedCampDialogue = availableCampDialogues.find( (dialogue) => dialogue.id === this.selectedDialogueId @@ -25470,6 +25871,63 @@ export class CampScene extends Phaser.Scene { ) } : null, + thirdCampPreparation: thirdCampPreparation + ? { + available: true, + sourceBattleId: thirdCampPreparation.sourceBattleId, + targetBattleId: thirdCampPreparation.targetBattleId, + selectionRecordId: thirdCampPreparation.selectionRecordId, + ready: thirdCampPreparation.ready, + selectedPriorityId: thirdCampPreparation.selectedPriorityId, + selectedLabel: thirdCampPreparation.selectedLabel, + browserOpen: this.thirdCampPreparationBrowserOpen, + toggleButtonBounds: this.sortieInteractiveObjectBoundsDebug( + this.thirdCampPreparationToggleButton + ), + closeButtonBounds: this.sortieInteractiveObjectBoundsDebug( + this.thirdCampPreparationCloseButton + ), + priorities: thirdCampPreparation.availability.map((entry) => { + const view = this.thirdCampPreparationCardViews.find( + (candidate) => candidate.priorityId === entry.priority.id + ); + return { + id: entry.priority.id, + label: entry.priority.label, + activityLabel: entry.priority.activityLabel, + summary: entry.priority.summary, + selectable: entry.selectable, + selected: thirdCampPreparation.selectedPriorityId === entry.priority.id, + evidenceKind: entry.evidence?.kind ?? null, + evidence: entry.evidence + ? entry.evidence.kind === 'priority-return-dialogue' + ? { + ...entry.evidence, + unitIds: [...entry.evidence.unitIds] + } + : { ...entry.evidence } + : null, + unavailableReason: entry.unavailableReason ?? null, + cardBounds: this.sortieObjectBoundsDebug(view?.background), + actionButtonBounds: this.sortieInteractiveObjectBoundsDebug( + view?.actionButton + ) + }; + }) + } + : { + available: false, + sourceBattleId: thirdCampPreparationSourceBattleId, + targetBattleId: thirdCampPreparationTargetBattleId, + selectionRecordId: null, + ready: false, + selectedPriorityId: null, + selectedLabel: null, + browserOpen: false, + toggleButtonBounds: null, + closeButtonBounds: null, + priorities: [] + }, firstPursuitScoutMemory: { visitId: firstPursuitScoutVisitId, available: Boolean(firstPursuitScoutVisit), diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index bffbd76..19cdf90 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -9,6 +9,14 @@ import type { UnitDirection } from '../data/unitAssets'; import { equipmentExpToNext, equipmentSlots, type EquipmentSlot } from '../data/battleItems'; import type { SortieOrderId } from '../data/sortieOrders'; import { coreSortieResonanceMinLevel } from '../data/sortieSynergy'; +import { + getThirdCampPreparationPriority, + isThirdCampPreparationPriorityId, + thirdCampPreparationInformationEnemyUnitId, + thirdCampPreparationSelectionRecordId, + thirdCampPreparationTargetBattleId, + type ThirdCampPreparationPriorityId +} from '../data/thirdCampPreparation'; export type BattleSaveFaction = 'ally' | 'enemy'; export type BattleSaveRosterTab = 'ally' | 'enemy'; @@ -91,6 +99,19 @@ export type BattleSaveCooperationStats = { additionalDamage: number; }; +export type BattleSaveThirdCampPreparationState = { + selectionRecordId: typeof thirdCampPreparationSelectionRecordId; + priorityId: ThirdCampPreparationPriorityId; + label: string; + markedEnemyUnitId?: typeof thirdCampPreparationInformationEnemyUnitId; + informationConsumed: boolean; + equipmentConsumed: boolean; + companionAttempts: number; + companionSuccesses: number; + usageCount: number; + preventedDamage: number; +}; + export type BattleSaveEventPriority = 'critical' | 'high' | 'normal' | 'low'; export type BattleSavePendingEvent = { @@ -111,6 +132,7 @@ export type BattleSaveState = { sortieRecommendation?: CampaignSortieRecommendationSnapshot; coreResonanceStats?: BattleSaveCoreResonanceStats; cooperationStatsByBond?: Record; + thirdCampPreparation?: BattleSaveThirdCampPreparationState; savedAt: string; turnNumber: number; activeFaction: BattleSaveFaction; @@ -207,6 +229,7 @@ export function normalizeBattleSaveState( normalizeCoreResonanceSaveFields(cloned, options); normalizeCooperationSaveFields(cloned, options); + normalizeThirdCampPreparationSaveField(cloned); const recommendation = normalizeCampaignSortieRecommendationSnapshot(cloned.sortieRecommendation, { expectedBattleId: typeof cloned.battleId === 'string' ? cloned.battleId : options.expectedBattleId, allowedUnitIds: options.validAllyUnitIds @@ -256,6 +279,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida return false; } + if (!isOptionalThirdCampPreparationState(state.thirdCampPreparation, state)) { + return false; + } + const attackIntents = state.attackIntents; const units = state.units; @@ -505,6 +532,141 @@ function isOptionalCooperationStatsByBond( )); } +function normalizeThirdCampPreparationSaveField( + state: Record +) { + if ( + state.battleId !== thirdCampPreparationTargetBattleId || + !isRecord(state.thirdCampPreparation) + ) { + delete state.thirdCampPreparation; + return; + } + + const raw = state.thirdCampPreparation; + const priorityId = isThirdCampPreparationPriorityId(raw.priorityId) + ? raw.priorityId + : undefined; + const priority = priorityId + ? getThirdCampPreparationPriority(priorityId) + : undefined; + if ( + !priorityId || + !priority || + raw.selectionRecordId !== thirdCampPreparationSelectionRecordId + ) { + delete state.thirdCampPreparation; + return; + } + + const informationConsumed = + priorityId === 'information' && raw.informationConsumed === true; + const equipmentConsumed = + priorityId === 'equipment' && raw.equipmentConsumed === true; + const companionAttempts = priorityId === 'companion' + ? normalizeCoreResonanceStatValue(raw.companionAttempts) + : 0; + const companionSuccesses = priorityId === 'companion' + ? Math.min( + companionAttempts, + normalizeCoreResonanceStatValue(raw.companionSuccesses) + ) + : 0; + const usageCount = priorityId === 'information' + ? Number(informationConsumed) + : priorityId === 'equipment' + ? Number(equipmentConsumed) + : companionAttempts; + const preventedDamage = priorityId === 'equipment' && equipmentConsumed + ? normalizeCoreResonanceStatValue(raw.preventedDamage) + : 0; + + state.thirdCampPreparation = { + selectionRecordId: thirdCampPreparationSelectionRecordId, + priorityId, + label: priority.label, + ...(priorityId === 'information' + ? { + markedEnemyUnitId: + thirdCampPreparationInformationEnemyUnitId + } + : {}), + informationConsumed, + equipmentConsumed, + companionAttempts, + companionSuccesses, + usageCount, + preventedDamage + } satisfies BattleSaveThirdCampPreparationState; +} + +function isOptionalThirdCampPreparationState( + value: unknown, + state: Record +) { + if (value === undefined) { + return true; + } + if ( + state.battleId !== thirdCampPreparationTargetBattleId || + !isRecord(value) || + value.selectionRecordId !== + thirdCampPreparationSelectionRecordId || + !isThirdCampPreparationPriorityId(value.priorityId) + ) { + return false; + } + const priority = getThirdCampPreparationPriority(value.priorityId); + if ( + !priority || + value.label !== priority.label || + typeof value.informationConsumed !== 'boolean' || + typeof value.equipmentConsumed !== 'boolean' || + !isBattleUnitStatValue(value.companionAttempts) || + !isBattleUnitStatValue(value.companionSuccesses) || + !isBattleUnitStatValue(value.usageCount) || + !isBattleUnitStatValue(value.preventedDamage) || + Number(value.companionSuccesses) > + Number(value.companionAttempts) + ) { + return false; + } + if (value.priorityId === 'information') { + return ( + value.markedEnemyUnitId === + thirdCampPreparationInformationEnemyUnitId && + value.equipmentConsumed === false && + Number(value.companionAttempts) === 0 && + Number(value.companionSuccesses) === 0 && + Number(value.usageCount) === + Number(value.informationConsumed) && + Number(value.preventedDamage) === 0 + ); + } + if (value.priorityId === 'equipment') { + return ( + value.markedEnemyUnitId === undefined && + value.informationConsumed === false && + Number(value.companionAttempts) === 0 && + Number(value.companionSuccesses) === 0 && + Number(value.usageCount) === + Number(value.equipmentConsumed) && + ( + value.equipmentConsumed === true || + Number(value.preventedDamage) === 0 + ) + ); + } + return ( + value.markedEnemyUnitId === undefined && + value.informationConsumed === false && + value.equipmentConsumed === false && + Number(value.usageCount) === + Number(value.companionAttempts) && + Number(value.preventedDamage) === 0 + ); +} + function isOptionalCoreResonanceState( state: Record, options: BattleSaveValidationOptions diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index d064fc5..fd3e07e 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -2,6 +2,14 @@ import { equipmentExpToNext, equipmentSlots, itemCatalog, type EquipmentSlot } f import { unitClasses } from '../data/battleRules'; import { battleScenarios, type BattleScenarioId } from '../data/battles'; import { findCityStayAfterBattle, type CityStayId } from '../data/cityStays'; +import { + isThirdCampPreparationPriorityId, + resolveThirdCampPreparationAvailability, + thirdCampPreparationSelectionRecordId, + thirdCampPreparationSourceBattleId, + thirdCampPreparationTargetBattleId, + type ThirdCampPreparationPriorityId +} from '../data/thirdCampPreparation'; import { campaignRecruitUnits, jianYongRecruitBond, @@ -81,6 +89,12 @@ export type CampaignVictoryRewardAcknowledgements = Partial; +export type ThirdCampPreparationSelectionSnapshot = { + sourceBattleId: typeof thirdCampPreparationSourceBattleId; + targetBattleId: typeof thirdCampPreparationTargetBattleId; + priorityId: ThirdCampPreparationPriorityId; +}; + const campaignVictoryRewardCategoryIdSet = new Set(campaignVictoryRewardCategoryIds); export type CampaignSortieUnitPerformanceSnapshot = { @@ -527,6 +541,7 @@ export type CampaignState = { completedCampVisits: string[]; campDialogueChoiceIds: CampaignCampChoiceHistory; campVisitChoiceIds: CampaignCampChoiceHistory; + thirdCampPreparationSelection?: ThirdCampPreparationSelectionSnapshot; acknowledgedVictoryRewardBattleIds: string[]; acknowledgedVictoryRewardCategories: CampaignVictoryRewardAcknowledgements; dismissedVictoryRewardNoticeBattleIds: string[]; @@ -1707,6 +1722,16 @@ function normalizeCampaignState(state: Partial): CampaignState { normalized.inventory = normalizeInventory(normalized.inventory); normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory); normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport); + delete normalized.campVisitChoiceIds[thirdCampPreparationSelectionRecordId]; + normalized.completedCampVisits = normalized.completedCampVisits.filter( + (visitId) => visitId !== thirdCampPreparationSelectionRecordId + ); + if (normalized.firstBattleReport) { + normalized.firstBattleReport.completedCampVisits = + normalized.firstBattleReport.completedCampVisits.filter( + (visitId) => visitId !== thirdCampPreparationSelectionRecordId + ); + } normalized.completedCampDialogues = mergeCampCompletionIds( normalized.completedCampDialogues, normalized.firstBattleReport?.completedCampDialogues ?? [], @@ -1852,6 +1877,13 @@ function normalizeCampaignState(state: Partial): CampaignState { const available = campaignVictoryRewardCategoriesFor(source); return available.length > 0 && available.every((category) => acknowledged.has(category)); }); + normalized.thirdCampPreparationSelection = normalizeThirdCampPreparationSelection( + normalized.thirdCampPreparationSelection, + normalized + ); + if (!normalized.thirdCampPreparationSelection) { + delete normalized.thirdCampPreparationSelection; + } normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory( normalized.sortieOrderHistory, normalized.battleHistory, @@ -1904,6 +1936,33 @@ function normalizeCampChoiceHistory(value: unknown): CampaignCampChoiceHistory { return history; } +function normalizeThirdCampPreparationSelection( + value: unknown, + campaign: CampaignState +): ThirdCampPreparationSelectionSnapshot | undefined { + if ( + !isPlainObject(value) || + value.sourceBattleId !== thirdCampPreparationSourceBattleId || + value.targetBattleId !== thirdCampPreparationTargetBattleId || + !isThirdCampPreparationPriorityId(value.priorityId) + ) { + return undefined; + } + const priorityAvailable = resolveThirdCampPreparationAvailability( + campaign + ).some( + (entry) => + entry.priority.id === value.priorityId && entry.selectable + ); + return priorityAvailable + ? { + sourceBattleId: thirdCampPreparationSourceBattleId, + targetBattleId: thirdCampPreparationTargetBattleId, + priorityId: value.priorityId + } + : undefined; +} + function uniqueCampHistoryIds(value: unknown): string[] { return [ ...new Set( diff --git a/src/game/state/thirdCampPreparationActions.ts b/src/game/state/thirdCampPreparationActions.ts new file mode 100644 index 0000000..aaaf0f3 --- /dev/null +++ b/src/game/state/thirdCampPreparationActions.ts @@ -0,0 +1,210 @@ +import { + getThirdCampPreparationPriority, + hasThirdCampPreparationSourceVictory, + isThirdCampPreparationPriorityId, + resolveThirdCampPreparationAvailability, + resolveThirdCampPreparationMemory, + resolveThirdCampPreparationSelection, + thirdCampPreparationSelectionRecordId, + thirdCampPreparationSourceBattleId, + thirdCampPreparationTargetBattleId, + type ThirdCampPreparationAvailability, + type ThirdCampPreparationMemory, + type ThirdCampPreparationPriorityDefinition, + type ThirdCampPreparationPriorityId +} from '../data/thirdCampPreparation'; +import { + getCampaignState, + setCampaignState, + type CampaignState +} from './campaignState'; + +export type ThirdCampPreparationSelectionFailure = + | 'invalid-campaign' + | 'invalid-priority' + | 'activity-required' + | 'save-unavailable'; + +export type ThirdCampPreparationSelectionResult = + | { + ok: true; + priority: ThirdCampPreparationPriorityDefinition; + memory: ThirdCampPreparationMemory; + campaign: CampaignState; + changed: boolean; + } + | { + ok: false; + reason: ThirdCampPreparationSelectionFailure; + }; + +export type ThirdCampPreparationClearResult = + | { + ok: true; + campaign: CampaignState; + changed: boolean; + } + | { + ok: false; + reason: 'invalid-campaign' | 'save-unavailable'; + }; + +export function thirdCampPreparationProgress( + campaign: CampaignState = getCampaignState() +) { + const availability = resolveThirdCampPreparationAvailability( + campaign + ); + const selection = resolveThirdCampPreparationSelection(campaign); + return { + sourceBattleId: thirdCampPreparationSourceBattleId, + targetBattleId: thirdCampPreparationTargetBattleId, + selectionRecordId: thirdCampPreparationSelectionRecordId, + availability, + selectedPriorityId: selection?.priorityId ?? null, + selectedLabel: selection?.label ?? null, + ready: selection?.selectable === true + }; +} + +export function setThirdCampPreparationPriority( + priorityId: string +): ThirdCampPreparationSelectionResult { + let snapshot: CampaignState; + try { + snapshot = getCampaignState(); + } catch { + return { ok: false, reason: 'save-unavailable' }; + } + if (!campaignSupportsThirdCampPreparation(snapshot)) { + return { ok: false, reason: 'invalid-campaign' }; + } + if (!isThirdCampPreparationPriorityId(priorityId)) { + return { ok: false, reason: 'invalid-priority' }; + } + + const availability = availabilityFor(snapshot, priorityId); + if (!availability?.selectable) { + return { ok: false, reason: 'activity-required' }; + } + const priority = getThirdCampPreparationPriority(priorityId); + if (!priority) { + return { ok: false, reason: 'invalid-priority' }; + } + + const previousPriorityId = + snapshot.thirdCampPreparationSelection?.priorityId; + if (previousPriorityId === priorityId) { + const memory = resolveThirdCampPreparationMemory({ + campaign: snapshot, + battleId: thirdCampPreparationTargetBattleId + }); + return memory + ? { + ok: true, + priority, + memory, + campaign: snapshot, + changed: false + } + : { ok: false, reason: 'activity-required' }; + } + + const updated: CampaignState = { + ...snapshot, + thirdCampPreparationSelection: { + sourceBattleId: thirdCampPreparationSourceBattleId, + targetBattleId: thirdCampPreparationTargetBattleId, + priorityId + } + }; + const persisted = persistSelection(updated, snapshot); + if (!persisted) { + return { ok: false, reason: 'save-unavailable' }; + } + + const memory = resolveThirdCampPreparationMemory({ + campaign: persisted, + battleId: thirdCampPreparationTargetBattleId + }); + if (!memory || memory.priorityId !== priorityId) { + restoreCampaignSnapshot(snapshot); + return { ok: false, reason: 'save-unavailable' }; + } + + return { + ok: true, + priority, + memory, + campaign: persisted, + changed: true + }; +} + +export function clearThirdCampPreparationPriority(): + ThirdCampPreparationClearResult { + let snapshot: CampaignState; + try { + snapshot = getCampaignState(); + } catch { + return { ok: false, reason: 'save-unavailable' }; + } + if (!campaignSupportsThirdCampPreparation(snapshot)) { + return { ok: false, reason: 'invalid-campaign' }; + } + if (!snapshot.thirdCampPreparationSelection) { + return { + ok: true, + campaign: snapshot, + changed: false + }; + } + + const updated: CampaignState = { + ...snapshot + }; + delete updated.thirdCampPreparationSelection; + const persisted = persistSelection(updated, snapshot); + return persisted + ? { ok: true, campaign: persisted, changed: true } + : { ok: false, reason: 'save-unavailable' }; +} + +function campaignSupportsThirdCampPreparation( + campaign: CampaignState +) { + return ( + campaign.step === 'third-camp' && + campaign.latestBattleId === thirdCampPreparationSourceBattleId && + hasThirdCampPreparationSourceVictory(campaign) + ); +} + +function availabilityFor( + campaign: CampaignState, + priorityId: ThirdCampPreparationPriorityId +): ThirdCampPreparationAvailability | undefined { + return resolveThirdCampPreparationAvailability(campaign).find( + (entry) => entry.priority.id === priorityId + ); +} + +function persistSelection( + updated: CampaignState, + snapshot: CampaignState +) { + try { + return setCampaignState(updated); + } catch { + restoreCampaignSnapshot(snapshot); + return undefined; + } +} + +function restoreCampaignSnapshot(snapshot: CampaignState) { + try { + setCampaignState(snapshot); + } catch { + // setCampaignState restores the in-memory snapshot before persistence. + } +}