From 17486efa8b3a0ad7df89c475a8ee837da2b0ea9c Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 27 Jul 2026 06:57:33 +0900 Subject: [PATCH] feat: carry first-battle camaraderie into sortie prep --- docs/game-design-improvement-goal.md | 23 +- package.json | 1 + ...verify-first-battle-camaraderie-memory.mjs | 450 +++++++++++ scripts/verify-release-candidate.mjs | 746 +++++++++++++++++- scripts/verify-static-data.mjs | 1 + src/game/data/firstBattleCamaraderieMemory.ts | 341 ++++++++ src/game/scenes/BattleScene.ts | 155 +++- src/game/scenes/CampScene.ts | 281 ++++++- src/game/state/battleSaveState.ts | 137 ++++ src/game/state/campaignState.ts | 161 +++- 10 files changed, 2207 insertions(+), 89 deletions(-) create mode 100644 scripts/verify-first-battle-camaraderie-memory.mjs create mode 100644 src/game/data/firstBattleCamaraderieMemory.ts diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index 5462f9a..c8ae84e 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -128,9 +128,26 @@ RPG로 다듬는다. - 미달했다면 같은 군령의 첫 미달 조건을 보완하고, 완수했다면 잔당 추격전의 성격에 맞는 기동 군령을 다음 학습 과제로 제안한다. -다음 초반 묶음은 전투에서 실제로 함께 협공·엄호·회복한 사건을 동료가 -짧게 기억하고, 거점의 공명 대화와 다음 전투의 작은 협동 효과로 연결하는 -것을 우선 검토한다. +### 완료한 5차 묶음: 실제 협공을 전우의 기억으로 + +- 첫 전투의 일반·핵심 공명 추격을 관계별로 기록하고, 시도·성공·추가 + 피해를 전투 저장과 결과 보고서에 함께 보존한다. +- 성공한 관계를 우선하되 성공 횟수, 추가 피해, 시도 횟수와 안정적인 + 관계 ID 순으로 대표 기억 하나를 결정한다. 실패한 시도만 있어도 + 엇갈린 호흡을 보완하는 대화로 회수한다. +- 첫 군영에 실제 두 사람과 결과를 반영한 `전우 기억` 대화를 추가하고, + 선택을 마친 뒤에도 출전 조합은 자동으로 바꾸지 않는다. +- 플레이어가 다음 황건 추격전에서 기억 속 두 사람을 직접 핵심 공명조로 + 지정한 경우에만 추격 확률을 `+5%p` 높인다. +- 후보 카드와 선택·전투 결과에는 기본 확률, 전우 기억 보너스와 최종 + 확률을 나누어 보여 주며, 다른 조합·무선택·이후 전투에는 보너스를 + 적용하지 않는다. +- 오래된 저장은 그대로 읽고, 오염된 관계·무장 참조는 제거하며, 전투 + 저장 재개와 두 번째 전투 재도전에서도 보너스를 중복 가산하지 않는다. + +다음 초반 묶음은 정보 수집·장비 구매·동료 대화 가운데 무엇을 먼저 +선택했는지가 출전 브리핑과 첫 턴의 전술 선택에 짧고 명확하게 되돌아오는 +경로를 감사한다. ## 장기 개선 순서 diff --git a/package.json b/package.json index 03fe703..d9092d6 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "verify:city-stays:browser": "node scripts/verify-city-stay-browser.mjs", "verify:prologue-village": "node scripts/verify-prologue-village-data.mjs", "verify:first-battle-camp-followup": "node scripts/verify-first-battle-camp-followup.mjs", + "verify:first-battle-camaraderie": "node scripts/verify-first-battle-camaraderie-memory.mjs", "verify:prologue-exploration-assets": "node scripts/verify-prologue-exploration-asset-data.mjs", "verify:exploration-characters": "node scripts/verify-exploration-character-asset-data.mjs", "verify:prologue-dialogue-portraits": "node scripts/verify-prologue-dialogue-portrait-data.mjs", diff --git a/scripts/verify-first-battle-camaraderie-memory.mjs b/scripts/verify-first-battle-camaraderie-memory.mjs new file mode 100644 index 0000000..0629445 --- /dev/null +++ b/scripts/verify-first-battle-camaraderie-memory.mjs @@ -0,0 +1,450 @@ +import { createServer } from 'vite'; + +const storage = new Map(); +globalThis.window = { + localStorage: { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + 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 memoryModule = await server.ssrLoadModule( + '/src/game/data/firstBattleCamaraderieMemory.ts' + ); + const campaignModule = await server.ssrLoadModule( + '/src/game/state/campaignState.ts' + ); + const battleSaveModule = await server.ssrLoadModule( + '/src/game/state/battleSaveState.ts' + ); + const { battleScenarios } = await server.ssrLoadModule( + '/src/game/data/battles.ts' + ); + + verifyDominantSelection(memoryModule); + verifyMemoryAndBonus(memoryModule); + verifyCampaignRoundTrip(memoryModule, campaignModule, battleScenarios); + verifyBattleSaveRoundTrip(battleSaveModule); + + console.log( + 'First-battle camaraderie memory verification passed (selection, campaign settlement, legacy battle save, and +5%p gate).' + ); +} finally { + await server.close(); +} + +function verifyDominantSelection(memoryModule) { + const bonds = [ + { id: 'bond-c', unitIds: ['liu-bei', 'guan-yu'] }, + { id: 'bond-a', unitIds: ['liu-bei', 'zhang-fei'] }, + { id: 'bond-b', unitIds: ['guan-yu', 'zhang-fei'] } + ]; + const statsByBond = { + 'bond-c': { attempts: 8, successes: 0, additionalDamage: 80 }, + 'bond-b': { attempts: 2, successes: 1, additionalDamage: 6 }, + 'bond-a': { attempts: 3, successes: 1, additionalDamage: 6 } + }; + const snapshot = memoryModule.selectDominantSortieCooperationSnapshot({ + bonds, + statsByBond + }); + assert( + snapshot?.bondId === 'bond-a' && + snapshot.attempts === 3 && + snapshot.successes === 1 && + snapshot.additionalDamage === 6, + `A successful pair must outrank a failure-only pair, then use success, damage, attempts, and stable id: ${JSON.stringify(snapshot)}` + ); + snapshot.unitIds[0] = 'mutated'; + assert( + bonds[1].unitIds[0] === 'liu-bei', + 'Dominant selection must clone the canonical bond pair.' + ); + + const failureOnly = memoryModule.selectDominantSortieCooperationSnapshot({ + bonds: [{ id: 'bond-c', unitIds: ['liu-bei', 'guan-yu'] }], + statsByBond: { + 'bond-c': { attempts: 2.9, successes: -4, additionalDamage: 99 } + } + }); + assert( + failureOnly?.attempts === 2 && + failureOnly.successes === 0 && + failureOnly.additionalDamage === 0, + `Failure-only cooperation must discard impossible damage: ${JSON.stringify(failureOnly)}` + ); +} + +function verifyMemoryAndBonus(memoryModule) { + const snapshot = { + version: 1, + bondId: 'liu-bei__zhang-fei', + unitIds: ['liu-bei', 'zhang-fei'], + attempts: 1, + successes: 1, + additionalDamage: 7 + }; + const campaign = { + firstBattleReport: { + battleId: 'second-battle-yellow-turban-pursuit', + outcome: 'defeat' + }, + battleHistory: { + 'first-battle-zhuo-commandery': { + battleId: 'first-battle-zhuo-commandery', + outcome: 'victory', + units: [ + { unitId: 'liu-bei', name: '유비' }, + { unitId: 'zhang-fei', name: '장비' } + ], + sortieCooperation: snapshot + }, + 'second-battle-yellow-turban-pursuit': { + battleId: 'second-battle-yellow-turban-pursuit', + outcome: 'defeat', + units: [] + } + }, + completedCampDialogues: [] + }; + const memory = memoryModule.resolveFirstBattleCamaraderieMemory(campaign); + assert( + memory?.bondId === snapshot.bondId && + memory.rememberedOutcome === 'success' && + memory.completed === false && + memory.dialogue.id === memoryModule.firstBattleCamaraderieDialogueId && + memory.dialogue.lines.length === 3 && + memory.dialogue.choices.every((choice) => choice.response.includes('+5%p')), + `Memory must come from the first victory settlement and explain the unlock for either choice: ${JSON.stringify(memory)}` + ); + assert( + memoryModule.firstBattleCamaraderieBonusFor({ + campaign, + battleId: memoryModule.firstBattleCamaraderieTargetBattleId, + coreBondId: snapshot.bondId + }) === 0, + 'The remembered pair must not receive a bonus before its camp dialogue is completed.' + ); + + campaign.completedCampDialogues.push( + memoryModule.firstBattleCamaraderieDialogueId + ); + assert( + memoryModule.firstBattleCamaraderieBonusFor({ + campaign, + battleId: memoryModule.firstBattleCamaraderieTargetBattleId, + coreBondId: snapshot.bondId + }) === 5 && + memoryModule.firstBattleCamaraderieBonusFor({ + campaign, + battleId: memoryModule.firstBattleCamaraderieTargetBattleId, + coreBondId: 'liu-bei__guan-yu' + }) === 0 && + memoryModule.firstBattleCamaraderieBonusFor({ + campaign, + battleId: 'third-battle-guangzong-road', + coreBondId: snapshot.bondId + }) === 0, + 'The +5%p bonus must require dialogue completion, the exact next battle, and the same manually selected core pair.' + ); + assert( + memoryModule.resolveFirstBattleCamaraderieMemory({ + battleHistory: { + 'first-battle-zhuo-commandery': { + ...campaign.battleHistory['first-battle-zhuo-commandery'], + outcome: 'defeat' + } + } + }) === undefined, + 'A first-battle defeat must never create a camaraderie memory.' + ); + + const attemptedCampaign = structuredClone(campaign); + attemptedCampaign.completedCampDialogues = []; + attemptedCampaign.battleHistory[ + memoryModule.firstBattleCamaraderieSourceBattleId + ].sortieCooperation = { + ...snapshot, + attempts: 2, + successes: 0, + additionalDamage: 99 + }; + const attemptedMemory = + memoryModule.resolveFirstBattleCamaraderieMemory(attemptedCampaign); + assert( + attemptedMemory?.rememberedOutcome === 'attempted' && + attemptedMemory.attempts === 2 && + attemptedMemory.successes === 0 && + attemptedMemory.additionalDamage === 0 && + attemptedMemory.dialogue.title.includes('엇갈린') && + attemptedMemory.dialogue.lines.length === 3 && + attemptedMemory.dialogue.choices.every( + (choice) => choice.response.includes('+5%p') + ), + `A failed-only pursuit must become a safe learning dialogue with no phantom damage: ${JSON.stringify(attemptedMemory)}` + ); +} + +function verifyCampaignRoundTrip( + memoryModule, + campaignModule, + battleScenarios +) { + storage.clear(); + campaignModule.resetCampaignState(); + const scenario = + battleScenarios[memoryModule.firstBattleCamaraderieSourceBattleId]; + const cooperation = { + version: 1, + bondId: 'liu-bei__zhang-fei', + unitIds: ['liu-bei', 'zhang-fei'], + attempts: 3, + successes: 1, + additionalDamage: 8 + }; + campaignModule.setFirstBattleReport({ + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: 5, + rewardGold: 100, + defeatedEnemies: scenario.units.filter((unit) => unit.faction === 'enemy') + .length, + totalEnemies: scenario.units.filter((unit) => unit.faction === 'enemy') + .length, + objectives: [], + units: scenario.units, + bonds: scenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })), + itemRewards: [], + sortieCooperation: cooperation, + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-27T00:00:00.000Z' + }); + + let campaign = campaignModule.getCampaignState(); + const settlement = + campaign.battleHistory[memoryModule.firstBattleCamaraderieSourceBattleId]; + assert( + JSON.stringify(campaign.firstBattleReport?.sortieCooperation) === + JSON.stringify(cooperation) && + JSON.stringify(settlement?.sortieCooperation) === + JSON.stringify(cooperation) && + campaign.firstBattleReport.sortieCooperation !== + settlement.sortieCooperation && + campaign.firstBattleReport.sortieCooperation.unitIds !== + settlement.sortieCooperation.unitIds, + 'Report-to-settlement creation must preserve and deep-clone the selected cooperation snapshot.' + ); + + campaign = campaignModule.loadCampaignState(); + assert( + campaign.battleHistory[memoryModule.firstBattleCamaraderieSourceBattleId] + ?.sortieCooperation?.bondId === cooperation.bondId, + 'A valid cooperation snapshot must survive a persisted campaign round trip.' + ); + const failedOnly = campaignModule.normalizeCampaignSortieCooperationSnapshot( + { + ...cooperation, + attempts: 2.8, + successes: -1, + additionalDamage: 100 + }, + { + allowedUnitIds: new Set(cooperation.unitIds), + bonds: scenario.bonds + } + ); + assert( + failedOnly?.attempts === 2 && + failedOnly.successes === 0 && + failedOnly.additionalDamage === 0, + `Campaign normalization must canonicalize failed-only totals: ${JSON.stringify(failedOnly)}` + ); + + const raw = JSON.parse(storage.get(campaignModule.campaignStorageKey)); + raw.firstBattleReport.sortieCooperation.unitIds[1] = 'ghost-unit'; + raw.battleHistory[ + memoryModule.firstBattleCamaraderieSourceBattleId + ].sortieCooperation.bondId = 'ghost-bond'; + storage.set(campaignModule.campaignStorageKey, JSON.stringify(raw)); + campaign = campaignModule.loadCampaignState(); + assert( + campaign.firstBattleReport?.sortieCooperation === undefined && + campaign.battleHistory[ + memoryModule.firstBattleCamaraderieSourceBattleId + ]?.sortieCooperation === undefined, + 'Unknown roster and bond references must be removed without rejecting the campaign save.' + ); +} + +function verifyBattleSaveRoundTrip(battleSaveModule) { + const options = { + expectedBattleId: 'first-battle-zhuo-commandery', + mapWidth: 12, + mapHeight: 8, + validUnitIds: new Set(['liu-bei', 'guan-yu', 'rebel-1']), + validAllyUnitIds: new Set(['liu-bei', 'guan-yu']), + validEnemyUnitIds: new Set(['rebel-1']), + validUsableIds: new Set(['bean']), + validItemIds: new Set(['bean']), + validEquipmentItemIds: new Set([ + 'training-sword', + 'cloth-armor', + 'grain-pouch' + ]), + validEquipmentSlotItems: { + weapon: new Set(['training-sword']), + armor: new Set(['cloth-armor']), + accessory: new Set(['grain-pouch']) + }, + validTriggeredBattleEventIds: new Set(['opening']) + }; + const base = createValidBattleSaveState(); + const normalized = battleSaveModule.normalizeBattleSaveState( + { + ...base, + cooperationStatsByBond: { + taoyuan: { + attempts: 2.9, + successes: -1, + additionalDamage: 100 + }, + 'ghost-bond': { + attempts: 9, + successes: 9, + additionalDamage: 99 + } + } + }, + options + ); + assert( + JSON.stringify(normalized?.cooperationStatsByBond) === + JSON.stringify({ + taoyuan: { attempts: 2, successes: 0, additionalDamage: 0 } + }), + `Battle saves must normalize per-bond totals and discard unknown bonds: ${JSON.stringify(normalized?.cooperationStatsByBond)}` + ); + + const legacy = battleSaveModule.normalizeBattleSaveState( + { + ...base, + sortieResonanceBondId: 'taoyuan', + coreResonanceStats: { + attempts: 3, + successes: 1, + failures: 2, + additionalDamage: 9 + } + }, + options + ); + assert( + JSON.stringify(legacy?.cooperationStatsByBond) === + JSON.stringify({ + taoyuan: { attempts: 3, successes: 1, additionalDamage: 9 } + }), + `Legacy core-resonance totals must backfill the generic per-bond record: ${JSON.stringify(legacy?.cooperationStatsByBond)}` + ); + assert( + !battleSaveModule.isValidBattleSaveState( + { + ...base, + cooperationStatsByBond: { + taoyuan: { attempts: 1, successes: 0, additionalDamage: 7 } + } + }, + options + ), + 'Direct validation must reject impossible failure-only additional damage.' + ); +} + +function createValidBattleSaveState() { + return { + version: 1, + battleId: 'first-battle-zhuo-commandery', + campaignStep: 'first-battle', + sortieOrderId: 'elite', + savedAt: '2026-07-27T00:00:00.000Z', + turnNumber: 2, + activeFaction: 'ally', + rosterTab: 'ally', + actedUnitIds: [], + attackIntents: [], + battleLog: [], + units: [ + savedUnit('liu-bei', 1, 2), + savedUnit('guan-yu', 2, 2), + savedUnit('rebel-1', 8, 4) + ], + bonds: [ + { + id: 'taoyuan', + unitIds: ['liu-bei', 'guan-yu'], + title: 'Oath', + level: 30, + exp: 0, + battleExp: 0, + description: 'Shared oath.' + } + ], + itemStocks: {}, + battleBuffs: [], + battleStatuses: [], + battleStats: {}, + enemyUsableUseKeys: [], + triggeredBattleEvents: [] + }; +} + +function savedUnit(id, x, y) { + return { + id, + level: 2, + exp: 0, + hp: 30, + maxHp: 30, + attack: 10, + move: 4, + stats: { + might: 70, + intelligence: 70, + leadership: 70, + agility: 70, + luck: 70 + }, + x, + y, + direction: 'south', + equipment: { + weapon: { itemId: 'training-sword', level: 1, exp: 0 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 0 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + } + }; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index b6c40f0..e55a88f 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -2109,6 +2109,191 @@ try { `Expected selecting 재정비 to consume the order command and resolve deterministic healing immediately: ${JSON.stringify(firstBattleCommand)}` ); + const firstBattleCamaraderieAttempt = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const attacker = scene?.debugUnitById?.('liu-bei'); + const partner = scene?.debugUnitById?.('zhang-fei'); + const targetState = window.__HEROS_DEBUG__?.battle()?.units?.find( + (unit) => unit.faction === 'enemy' && unit.hp > 0 + ); + const target = targetState ? scene?.debugUnitById?.(targetState.id) : undefined; + if (!scene || !attacker || !partner || !target) { + return { + ready: false, + reason: 'first-battle Liu Bei/Zhang Fei cooperation fixture units were unavailable', + targetId: targetState?.id ?? null + }; + } + + const clone = (value) => value === undefined ? undefined : structuredClone(value); + const touchedUnits = [attacker, partner, target]; + const original = { + units: touchedUnits.map((unit) => ({ + id: unit.id, + x: unit.x, + y: unit.y, + hp: unit.hp, + maxHp: unit.maxHp, + direction: scene.unitViews.get(unit.id)?.direction ?? 'south' + })), + attackIntents: scene.attackIntents.map((intent) => ({ ...intent })), + actedUnitIds: [...scene.actedUnitIds].sort(), + battleStats: clone(scene.serializeBattleStats()) + }; + let preview; + let resolution; + try { + Object.assign(attacker, { x: 5, y: 5 }); + Object.assign(partner, { x: 4, y: 5 }); + Object.assign(target, { + x: 6, + y: 5, + maxHp: Math.max(target.maxHp, 5000), + hp: Math.max(target.maxHp, 5000) + }); + scene.attackIntents = [{ attackerId: partner.id, targetId: target.id }]; + scene.actedUnitIds = new Set([...scene.actedUnitIds, partner.id]); + touchedUnits.forEach((unit) => scene.positionUnitView(unit)); + + const livePreview = scene.combatPreview(attacker, target, 'attack'); + const liveResolution = scene.resolveBondChainFollowUp(livePreview, true, true); + preview = { + bondId: livePreview.bondChainBondId ?? null, + partnerId: livePreview.bondChainPartnerId ?? null, + rate: livePreview.bondChainRate, + damage: livePreview.bondChainDamage, + coreResonance: livePreview.bondChainCoreResonance + }; + resolution = liveResolution + ? { + bondId: liveResolution.attempt.bondId, + partnerId: liveResolution.attempt.partnerId, + rate: liveResolution.attempt.rate, + succeeded: liveResolution.attempt.succeeded, + coreResonance: liveResolution.attempt.coreResonance, + damage: liveResolution.result?.damage ?? 0 + } + : null; + } finally { + original.units.forEach((snapshot) => { + const unit = scene.debugUnitById(snapshot.id); + if (!unit) { + return; + } + Object.assign(unit, { + x: snapshot.x, + y: snapshot.y, + hp: snapshot.hp, + maxHp: snapshot.maxHp + }); + scene.positionUnitView(unit); + scene.syncUnitMotion(unit, scene.unitViews.get(unit.id), snapshot.direction); + }); + scene.battleStats = new Map( + Object.entries(original.battleStats).map(([unitId, stats]) => [unitId, clone(stats)]) + ); + scene.attackIntents = original.attackIntents.map((intent) => ({ ...intent })); + scene.actedUnitIds = new Set(original.actedUnitIds); + scene.refreshUnitLegibilityStyles(); + } + + const restored = { + units: original.units.map(({ id }) => { + const unit = scene.debugUnitById(id); + return unit + ? { + id: unit.id, + x: unit.x, + y: unit.y, + hp: unit.hp, + maxHp: unit.maxHp, + direction: scene.unitViews.get(unit.id)?.direction ?? null + } + : null; + }), + attackIntents: scene.attackIntents.map((intent) => ({ ...intent })), + actedUnitIds: [...scene.actedUnitIds].sort(), + battleStats: clone(scene.serializeBattleStats()) + }; + const tracked = clone( + window.__HEROS_DEBUG__?.battle()?.coreSortieResonance?.cooperation ?? null + ); + scene.saveBattleState?.(1); + const battleKey = 'heros-web:battle:first-battle-zhuo-commandery'; + const read = (key) => { + const raw = window.localStorage.getItem(key); + return raw ? JSON.parse(raw) : null; + }; + return { + ready: true, + preview, + resolution, + tracked, + restored, + expectedRestored: original, + battleSave: { + current: read(battleKey), + slot1: read(`${battleKey}:slot-1`), + normalizedSlot1: scene.readBattleSaveState?.(1) ?? null + } + }; + }); + const expectedFirstBattleCooperation = { + version: 1, + bondId: 'liu-bei__zhang-fei', + unitIds: ['liu-bei', 'zhang-fei'], + attempts: 1, + successes: 1, + additionalDamage: firstBattleCamaraderieAttempt.resolution?.damage + }; + const expectedFirstBattleCooperationStats = { + attempts: 1, + successes: 1, + additionalDamage: firstBattleCamaraderieAttempt.resolution?.damage + }; + assert( + firstBattleCamaraderieAttempt.ready === true && + firstBattleCamaraderieAttempt.preview?.bondId === 'liu-bei__zhang-fei' && + firstBattleCamaraderieAttempt.preview.partnerId === 'zhang-fei' && + firstBattleCamaraderieAttempt.preview.rate === 8 && + firstBattleCamaraderieAttempt.preview.damage > 0 && + firstBattleCamaraderieAttempt.preview.coreResonance === false && + firstBattleCamaraderieAttempt.resolution?.bondId === 'liu-bei__zhang-fei' && + firstBattleCamaraderieAttempt.resolution.partnerId === 'zhang-fei' && + firstBattleCamaraderieAttempt.resolution.rate === 8 && + firstBattleCamaraderieAttempt.resolution.succeeded === true && + firstBattleCamaraderieAttempt.resolution.coreResonance === false && + firstBattleCamaraderieAttempt.resolution.damage > 0, + `Expected one retained, actual ordinary Liu Bei/Zhang Fei pursuit success before first victory: ${JSON.stringify(firstBattleCamaraderieAttempt)}` + ); + assert( + sameJsonValue( + firstBattleCamaraderieAttempt.tracked?.statsByBond?.['liu-bei__zhang-fei'], + expectedFirstBattleCooperationStats + ) && + sameJsonValue( + firstBattleCamaraderieAttempt.tracked?.dominantSnapshot, + expectedFirstBattleCooperation + ) && + sameJsonValue( + firstBattleCamaraderieAttempt.battleSave.current?.cooperationStatsByBond?.['liu-bei__zhang-fei'], + expectedFirstBattleCooperationStats + ) && + sameJsonValue( + firstBattleCamaraderieAttempt.battleSave.slot1?.cooperationStatsByBond?.['liu-bei__zhang-fei'], + expectedFirstBattleCooperationStats + ) && + sameJsonValue( + firstBattleCamaraderieAttempt.battleSave.normalizedSlot1?.cooperationStatsByBond?.['liu-bei__zhang-fei'], + expectedFirstBattleCooperationStats + ), + `Expected retained cooperation statistics to synchronize through debug, raw current/slot saves, and the normalized runtime slot reader: ${JSON.stringify(firstBattleCamaraderieAttempt)}` + ); + assert( + sameJsonValue(firstBattleCamaraderieAttempt.restored, firstBattleCamaraderieAttempt.expectedRestored), + `Expected the retained cooperation fixture to restore positions, HP, battle stats, intents, and acted state: ${JSON.stringify(firstBattleCamaraderieAttempt)}` + ); + await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await waitForBattleOutcome(page, 'victory'); const initialFirstSettlement = await page.evaluate(() => ({ @@ -2203,6 +2388,31 @@ try { firstResultSave.slot1?.pendingAftermathBattleId === 'first-battle-zhuo-commandery', `Expected the victory result to persist its unseen aftermath in current and slot saves: ${JSON.stringify(firstResultSave)}` ); + assert( + sameJsonValue( + firstResultSave.current?.firstBattleReport?.sortieCooperation, + expectedFirstBattleCooperation + ) && + sameJsonValue( + firstResultSave.slot1?.firstBattleReport?.sortieCooperation, + expectedFirstBattleCooperation + ) && + sameJsonValue( + firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieCooperation, + expectedFirstBattleCooperation + ) && + sameJsonValue( + firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieCooperation, + expectedFirstBattleCooperation + ), + `Expected the exact first-battle cooperation memory to persist across current/slot reports and battle history: ${JSON.stringify({ + expected: expectedFirstBattleCooperation, + currentReport: firstResultSave.current?.firstBattleReport?.sortieCooperation, + slotReport: firstResultSave.slot1?.firstBattleReport?.sortieCooperation, + currentHistory: firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieCooperation, + slotHistory: firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieCooperation + })}` + ); const firstSortieOrder = firstResultState?.sortieOperationOrder; const firstSortieOrderResult = firstSortieOrder?.result; const firstSortieOrderCommand = firstSortieOrderResult?.command; @@ -2703,6 +2913,8 @@ try { }); const firstVictoryFollowup = firstVictoryDialogueProbe.state?.firstVictoryFollowup; const firstVictorySelectedDialogue = firstVictoryDialogueProbe.state?.selectedDialogue; + const pendingFirstBattleCamaraderieMemory = + firstVictoryDialogueProbe.state?.firstBattleCamaraderieMemory; assert( firstVictoryDialogueProbe.state?.activeTab === 'dialogue' && firstVictoryDialogueProbe.state?.campTabs?.find((tab) => tab.id === 'dialogue')?.newBadgeVisible === true && @@ -2735,6 +2947,37 @@ try { firstVictorySelectedDialogue.choices.every((choice) => boundsWithinFhdViewport(choice.bounds)), `Expected the first-camp dialogue tab to render one marked Liu Bei/Guan Yu follow-up from the persisted elite victory: ${JSON.stringify(firstVictoryDialogueProbe)}` ); + assert( + pendingFirstBattleCamaraderieMemory?.available === true && + pendingFirstBattleCamaraderieMemory.sourceBattleId === 'first-battle-zhuo-commandery' && + pendingFirstBattleCamaraderieMemory.targetBattleId === 'second-battle-yellow-turban-pursuit' && + pendingFirstBattleCamaraderieMemory.bondId === 'liu-bei__zhang-fei' && + sameJsonValue(pendingFirstBattleCamaraderieMemory.unitIds, ['liu-bei', 'zhang-fei']) && + sameJsonValue(pendingFirstBattleCamaraderieMemory.unitNames, ['유비', '장비']) && + pendingFirstBattleCamaraderieMemory.attempts === 1 && + pendingFirstBattleCamaraderieMemory.successes === 1 && + pendingFirstBattleCamaraderieMemory.additionalDamage === + expectedFirstBattleCooperation.additionalDamage && + pendingFirstBattleCamaraderieMemory.rememberedOutcome === 'success' && + pendingFirstBattleCamaraderieMemory.completed === false && + pendingFirstBattleCamaraderieMemory.dialogue?.id === 'first-battle-camaraderie-memory' && + pendingFirstBattleCamaraderieMemory.dialogue.selected === false && + pendingFirstBattleCamaraderieMemory.dialogue.completed === false && + pendingFirstBattleCamaraderieMemory.dialogue.choiceId === null && + isFiniteBounds(pendingFirstBattleCamaraderieMemory.dialogue.rowBounds) && + pendingFirstBattleCamaraderieMemory.perk?.requiresManualSelection === true && + pendingFirstBattleCamaraderieMemory.perk.autoSelected === false && + pendingFirstBattleCamaraderieMemory.perk.exactPairOnly === true && + pendingFirstBattleCamaraderieMemory.perk.unlocked === false && + pendingFirstBattleCamaraderieMemory.perk.expectedBonusRate === 5 && + pendingFirstBattleCamaraderieMemory.perk.selected === false && + pendingFirstBattleCamaraderieMemory.perk.active === false && + pendingFirstBattleCamaraderieMemory.perk.baseChainRate === 18 && + pendingFirstBattleCamaraderieMemory.perk.bonusChainRate === 0 && + pendingFirstBattleCamaraderieMemory.perk.effectiveChainRate === 18 && + firstVictoryDialogueProbe.state?.campaign?.sortieResonanceSelection === null, + `Expected the actual Liu Bei/Zhang Fei success to create one incomplete memory dialogue without auto-selecting a core pair: ${JSON.stringify(pendingFirstBattleCamaraderieMemory)}` + ); await page.screenshot({ path: `${screenshotDir}/rc-first-camp-victory-followup-dialogue.png`, fullPage: true @@ -2788,17 +3031,181 @@ try { ] === firstVictoryChoice.id && completedFirstVictoryDialogueProbe.state?.campTabs?.find( (tab) => tab.id === 'dialogue' - )?.newBadgeVisible === false && + )?.newBadgeVisible === true && completedFirstVictoryDialogueProbe.remainingRowMarkerCount === 0 && firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.current) && firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.slot1), - `Expected the actual first-victory choice to persist in current and slot saves and clear both follow-up markers: ${JSON.stringify({ + `Expected the first-victory choice to persist while the separate camaraderie memory keeps the dialogue NEW state pending: ${JSON.stringify({ probe: completedFirstVictoryDialogueProbe, save: completedFirstVictoryDialogueSave, choiceId: firstVictoryChoice.id })}` ); + const firstBattleCamaraderieDialogueRow = await readCampDialogueControlPoint( + page, + 'row', + 'first-battle-camaraderie-memory' + ); + await page.mouse.click( + firstBattleCamaraderieDialogueRow.x, + firstBattleCamaraderieDialogueRow.y + ); + await page.waitForFunction( + () => ( + window.__HEROS_DEBUG__?.camp()?.selectedDialogue?.id === + 'first-battle-camaraderie-memory' + ), + undefined, + { timeout: 30000 } + ); + const firstBattleCamaraderieDialogueProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp(); + const memory = state?.firstBattleCamaraderieMemory; + const renderedDialogueText = memory?.dialogue?.lines?.join('\n') ?? null; + const activeTexts = (scene?.contentObjects ?? []).filter( + (object) => object?.type === 'Text' && object.active && object.visible + ); + const boundsFor = (object) => { + const bounds = object?.getBounds?.(); + return bounds + ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } + : null; + }; + return { + state, + noticeObjectCount: (scene?.dialogueObjects ?? []).filter( + (object) => object?.active + ).length, + memoryBadgeCount: activeTexts.filter((object) => object.text === '전우 기억').length, + renderedDialogueBlocks: renderedDialogueText + ? activeTexts + .filter((object) => object.text === renderedDialogueText) + .map((object) => ({ text: object.text, bounds: boundsFor(object) })) + : [] + }; + }); + const selectedFirstBattleCamaraderieMemory = + firstBattleCamaraderieDialogueProbe.state?.firstBattleCamaraderieMemory; + const selectedFirstBattleCamaraderieDialogue = + firstBattleCamaraderieDialogueProbe.state?.selectedDialogue; + assert( + selectedFirstBattleCamaraderieMemory?.available === true && + selectedFirstBattleCamaraderieMemory.completed === false && + selectedFirstBattleCamaraderieMemory.dialogue?.selected === true && + selectedFirstBattleCamaraderieMemory.dialogue.completed === false && + selectedFirstBattleCamaraderieMemory.dialogue.title.includes('유비') && + selectedFirstBattleCamaraderieMemory.dialogue.title.includes('장비') && + selectedFirstBattleCamaraderieMemory.dialogue.lines?.length === 3 && + selectedFirstBattleCamaraderieMemory.dialogue.lines.some((line) => line.includes('지난 싸움')) && + selectedFirstBattleCamaraderieMemory.dialogue.lines.some((line) => line.includes('핵심 공명조')) && + selectedFirstBattleCamaraderieMemory.dialogue.choices?.length === 2 && + selectedFirstBattleCamaraderieMemory.dialogue.choices[0]?.id === + 'trust-remembered-rhythm' && + selectedFirstBattleCamaraderieMemory.dialogue.choices[1]?.id === + 'review-successful-signals' && + selectedFirstBattleCamaraderieMemory.dialogue.choices.every( + (choice) => isFiniteBounds(choice.bounds) && boundsWithinFhdViewport(choice.bounds) + ) && + isFiniteBounds(selectedFirstBattleCamaraderieMemory.dialogue.rowBounds) && + isFiniteBounds(selectedFirstBattleCamaraderieMemory.dialogue.bodyBounds) && + selectedFirstBattleCamaraderieMemory.dialogue.bodyBounds.y + + selectedFirstBattleCamaraderieMemory.dialogue.bodyBounds.height <= + selectedFirstBattleCamaraderieMemory.dialogue.choices[0]?.bounds?.y - 8 && + selectedFirstBattleCamaraderieDialogue?.id === + 'first-battle-camaraderie-memory' && + selectedFirstBattleCamaraderieDialogue.camaraderieMemory === true && + selectedFirstBattleCamaraderieDialogue.completed === false && + sameJsonValue( + selectedFirstBattleCamaraderieDialogue.lines, + selectedFirstBattleCamaraderieMemory.dialogue.lines + ) && + firstBattleCamaraderieDialogueProbe.memoryBadgeCount === 1 && + firstBattleCamaraderieDialogueProbe.renderedDialogueBlocks.length === 1 && + selectedFirstBattleCamaraderieMemory.perk?.unlocked === false && + selectedFirstBattleCamaraderieMemory.perk.selected === false && + selectedFirstBattleCamaraderieMemory.perk.active === false && + firstBattleCamaraderieDialogueProbe.noticeObjectCount === 0 && + firstBattleCamaraderieDialogueProbe.state?.campaign?.sortieResonanceSelection === null, + `Expected the dynamic first-battle camaraderie row, body, choices, and bounds to render without selecting the next core pair: ${JSON.stringify(firstBattleCamaraderieDialogueProbe)}` + ); + await page.screenshot({ + path: `${screenshotDir}/rc-first-camp-camaraderie-memory-dialogue.png`, + fullPage: true + }); + await assertCanvasPainted(page, 'first camp camaraderie memory dialogue'); + + const firstBattleCamaraderieChoice = + selectedFirstBattleCamaraderieMemory.dialogue.choices[0]; + const firstBattleCamaraderieChoicePoint = await readCampDialogueControlPoint( + page, + 'choice', + 'first-battle-camaraderie-memory', + firstBattleCamaraderieChoice.id + ); + await page.mouse.click( + firstBattleCamaraderieChoicePoint.x, + firstBattleCamaraderieChoicePoint.y + ); + await page.waitForFunction( + ({ dialogueId, choiceId }) => { + const camp = window.__HEROS_DEBUG__?.camp(); + return ( + camp?.firstBattleCamaraderieMemory?.completed === true && + camp?.firstBattleCamaraderieMemory?.dialogue?.completed === true && + camp?.firstBattleCamaraderieMemory?.dialogue?.choiceId === choiceId && + camp?.campaign?.campDialogueChoiceIds?.[dialogueId] === choiceId + ); + }, + { + dialogueId: 'first-battle-camaraderie-memory', + choiceId: firstBattleCamaraderieChoice.id + }, + { timeout: 30000 } + ); + const completedFirstBattleCamaraderieState = + await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const completedFirstBattleCamaraderieMemory = + completedFirstBattleCamaraderieState?.firstBattleCamaraderieMemory; + const completedFirstBattleCamaraderieSave = await readCampaignSave(page); + const firstBattleCamaraderiePersisted = (save) => ( + save?.completedCampDialogues?.includes('first-battle-camaraderie-memory') && + save?.firstBattleReport?.completedCampDialogues?.includes( + 'first-battle-camaraderie-memory' + ) && + save?.campDialogueChoiceIds?.['first-battle-camaraderie-memory'] === + firstBattleCamaraderieChoice.id && + sameJsonValue( + save?.battleHistory?.['first-battle-zhuo-commandery']?.sortieCooperation, + expectedFirstBattleCooperation + ) + ); + assert( + completedFirstBattleCamaraderieMemory?.completed === true && + completedFirstBattleCamaraderieMemory.dialogue?.completed === true && + completedFirstBattleCamaraderieMemory.dialogue.choiceId === + firstBattleCamaraderieChoice.id && + completedFirstBattleCamaraderieMemory.perk?.unlocked === true && + completedFirstBattleCamaraderieMemory.perk.requiresManualSelection === true && + completedFirstBattleCamaraderieMemory.perk.autoSelected === false && + completedFirstBattleCamaraderieMemory.perk.selected === false && + completedFirstBattleCamaraderieMemory.perk.active === false && + completedFirstBattleCamaraderieMemory.perk.baseChainRate === 18 && + completedFirstBattleCamaraderieMemory.perk.bonusChainRate === 5 && + completedFirstBattleCamaraderieMemory.perk.effectiveChainRate === 23 && + completedFirstBattleCamaraderieState?.campaign?.sortieResonanceSelection === null && + completedFirstBattleCamaraderieState?.campTabs?.find( + (tab) => tab.id === 'dialogue' + )?.newBadgeVisible === false && + firstBattleCamaraderiePersisted(completedFirstBattleCamaraderieSave.current) && + firstBattleCamaraderiePersisted(completedFirstBattleCamaraderieSave.slot1), + `Expected the chosen camaraderie memory to persist in current/slot saves, unlock +5%p, and still avoid auto-selection: ${JSON.stringify({ + state: completedFirstBattleCamaraderieState, + save: completedFirstBattleCamaraderieSave + })}` + ); + const firstCampEvaluation = firstCampProbe.state?.reportFormationEvaluation; const firstCampHistory = firstCampProbe.state?.reportFormationHistory; const firstCampEvaluationSaveBefore = await readCampaignSave(page); @@ -3737,20 +4144,54 @@ try { id: candidate.id, level: candidate.level, baseChainRate: candidate.baseChainRate, + baseCoreChainRate: candidate.baseCoreChainRate, + bonusChainRate: candidate.bonusChainRate, + effectiveCoreChainRate: candidate.effectiveCoreChainRate, + camaraderieRemembered: candidate.camaraderieRemembered, coreChainRate: candidate.coreChainRate, selected: candidate.selected })), [ - { id: 'liu-bei__guan-yu', level: 72, baseChainRate: 18, coreChainRate: 28, selected: false }, - { id: 'liu-bei__zhang-fei', level: 68, baseChainRate: 8, coreChainRate: 18, selected: false }, - { id: 'guan-yu__zhang-fei', level: 64, baseChainRate: 8, coreChainRate: 18, selected: false } + { + id: 'liu-bei__zhang-fei', + level: 68, + baseChainRate: 8, + baseCoreChainRate: 18, + bonusChainRate: 5, + effectiveCoreChainRate: 23, + camaraderieRemembered: true, + coreChainRate: 23, + selected: false + }, + { + id: 'liu-bei__guan-yu', + level: 72, + baseChainRate: 18, + baseCoreChainRate: 28, + bonusChainRate: 0, + effectiveCoreChainRate: 28, + camaraderieRemembered: false, + coreChainRate: 28, + selected: false + }, + { + id: 'guan-yu__zhang-fei', + level: 64, + baseChainRate: 8, + baseCoreChainRate: 18, + bonusChainRate: 0, + effectiveCoreChainRate: 18, + camaraderieRemembered: false, + coreChainRate: 18, + selected: false + } ] ), - `Expected the selected founding trio to expose ordered 18/8/8 resonance-pursuit pairs without reserve opportunities: ${JSON.stringify(firstSortiePursuit)}` + `Expected the founding trio to retain ordinary pursuits while the remembered Liu Bei/Zhang Fei core candidate previews 18% + 5%p = 23%: ${JSON.stringify(firstSortiePursuit)}` ); assert( firstSortiePursuit?.summaryText === '핵심 공명조 · 미지정 · 후보 3조' && - firstSortiePursuit?.ruleText === 'Lv30+ 한 조 · 핵심 추격 8/18/28% · 재클릭 해제' && + firstSortiePursuit?.ruleText === '전우 기억한 조 직접 지정 · 핵심 추격 +5%p · 재클릭 해제' && isFiniteBounds(firstSortiePursuit.panelBounds) && isFiniteBounds(firstSortiePursuit.ruleBounds) && firstSortiePursuit.page === 0 && @@ -3766,11 +4207,54 @@ try { boundsInside(firstSortiePursuit.nextBounds, firstSortiePursuit.panelBounds) && boundsInside(firstSortiePursuit.panelBounds, logicalViewportBounds) && boundsInside(firstSortiePursuit.ruleBounds, firstSortiePursuit.panelBounds) && - sameJsonValue(firstSortiePursuit.rows?.map((row) => ({ bondId: row.bondId, state: row.state, selected: row.selected, chainRate: row.chainRate, text: row.text })), [ - { bondId: 'liu-bei__guan-yu', state: 'candidate', selected: false, chainRate: 28, text: '지정 28% 유비↔관우' }, - { bondId: 'liu-bei__zhang-fei', state: 'candidate', selected: false, chainRate: 18, text: '지정 18% 유비↔장비' }, - { bondId: 'guan-yu__zhang-fei', state: 'candidate', selected: false, chainRate: 18, text: '지정 18% 관우↔장비' } - ]) && + sameJsonValue( + firstSortiePursuit.rows?.map((row) => ({ + bondId: row.bondId, + state: row.state, + selected: row.selected, + baseChainRate: row.baseChainRate, + bonusChainRate: row.bonusChainRate, + effectiveChainRate: row.effectiveChainRate, + camaraderieRemembered: row.camaraderieRemembered, + chainRate: row.chainRate, + text: row.text + })), + [ + { + bondId: 'liu-bei__zhang-fei', + state: 'candidate', + selected: false, + baseChainRate: 18, + bonusChainRate: 5, + effectiveChainRate: 23, + camaraderieRemembered: true, + chainRate: 23, + text: '지정 18→23% · 유비↔장비' + }, + { + bondId: 'liu-bei__guan-yu', + state: 'candidate', + selected: false, + baseChainRate: 28, + bonusChainRate: 0, + effectiveChainRate: 28, + camaraderieRemembered: false, + chainRate: 28, + text: '지정 28% · 유비↔관우' + }, + { + bondId: 'guan-yu__zhang-fei', + state: 'candidate', + selected: false, + baseChainRate: 18, + bonusChainRate: 0, + effectiveChainRate: 18, + camaraderieRemembered: false, + chainRate: 18, + text: '지정 18% · 관우↔장비' + } + ] + ) && firstSortiePursuit.rows.every( (row) => isFiniteBounds(row.bounds) && @@ -3799,25 +4283,53 @@ try { return pursuit?.selectedCoreBondId === 'liu-bei__zhang-fei' && pursuit?.rows?.some((row) => row.bondId === 'liu-bei__zhang-fei' && row.selected === true); }, undefined, { timeout: 30000 }); - const selectedCoreResonance = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); + const selectedCoreResonanceCampState = + await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const selectedCoreResonance = selectedCoreResonanceCampState?.sortiePursuitPreview; + const selectedCamaraderiePerk = + selectedCoreResonanceCampState?.firstBattleCamaraderieMemory?.perk; const selectedCoreResonanceSave = await readCampaignSave(page); await page.screenshot({ path: `${screenshotDir}/rc-first-camp-core-resonance-selected.png`, fullPage: true }); + await page.screenshot({ path: `${screenshotDir}/rc-first-camp-camaraderie-perk-selected.png`, fullPage: true }); + await assertCanvasPainted(page, 'first camp selected camaraderie perk'); assert( selectedCoreResonance?.selectionBattleId === 'second-battle-yellow-turban-pursuit' && selectedCoreResonance?.selectedCoreBondId === 'liu-bei__zhang-fei' && - selectedCoreResonance?.summaryText === '핵심 공명조 · 지정 · 후보 3조' && + selectedCoreResonance?.summaryText === '핵심 공명조 · 23% 지정 · 후보 3조' && selectedCoreResonance?.activePairs?.[0]?.id === 'liu-bei__zhang-fei' && selectedCoreResonance.activePairs[0].core === true && selectedCoreResonance.activePairs[0].baseChainRate === 8 && - selectedCoreResonance.activePairs[0].chainRate === 18 && + selectedCoreResonance.activePairs[0].chainRate === 23 && selectedCoreResonance?.coreCandidates?.[0]?.id === 'liu-bei__zhang-fei' && + selectedCoreResonance.coreCandidates[0].baseCoreChainRate === 18 && + selectedCoreResonance.coreCandidates[0].bonusChainRate === 5 && + selectedCoreResonance.coreCandidates[0].effectiveCoreChainRate === 23 && + selectedCoreResonance.coreCandidates[0].camaraderieRemembered === true && selectedCoreResonance.coreCandidates[0].selected === true && selectedCoreResonance?.rows?.[0]?.bondId === 'liu-bei__zhang-fei' && + selectedCoreResonance.rows[0].baseChainRate === 18 && + selectedCoreResonance.rows[0].bonusChainRate === 5 && + selectedCoreResonance.rows[0].effectiveChainRate === 23 && + selectedCoreResonance.rows[0].camaraderieRemembered === true && selectedCoreResonance.rows[0].state === 'selected' && - selectedCoreResonance.rows[0].text === '핵심 18% 유비↔장비' && + selectedCoreResonance.rows[0].text === '핵심 18→23% · 유비↔장비' && + selectedCamaraderiePerk?.unlocked === true && + selectedCamaraderiePerk.requiresManualSelection === true && + selectedCamaraderiePerk.autoSelected === false && + selectedCamaraderiePerk.exactPairOnly === true && + selectedCamaraderiePerk.selected === true && + selectedCamaraderiePerk.active === true && + selectedCamaraderiePerk.baseChainRate === 18 && + selectedCamaraderiePerk.bonusChainRate === 5 && + selectedCamaraderiePerk.effectiveChainRate === 23 && + selectedCamaraderiePerk.cardText === '핵심 18→23% · 유비↔장비' && + isFiniteBounds(selectedCamaraderiePerk.cardBounds) && + boundsInside(selectedCamaraderiePerk.cardBounds, selectedCoreResonance.panelBounds) && selectedCoreResonanceSave.current?.sortieResonanceSelection?.battleId === 'second-battle-yellow-turban-pursuit' && - selectedCoreResonanceSave.current.sortieResonanceSelection.bondId === 'liu-bei__zhang-fei', - `Expected one clicked pair to become the persisted, prioritized 18% core resonance: ${JSON.stringify({ selectedCoreResonance, save: selectedCoreResonanceSave.current?.sortieResonanceSelection })}` + selectedCoreResonanceSave.current.sortieResonanceSelection.bondId === 'liu-bei__zhang-fei' && + selectedCoreResonanceSave.slot1?.sortieResonanceSelection?.battleId === 'second-battle-yellow-turban-pursuit' && + selectedCoreResonanceSave.slot1.sortieResonanceSelection.bondId === 'liu-bei__zhang-fei', + `Expected one manual click to activate and visibly persist the remembered pair at 18% + 5%p = 23%: ${JSON.stringify({ selectedCoreResonance, selectedCamaraderiePerk, save: selectedCoreResonanceSave.current?.sortieResonanceSelection })}` ); const coreResonanceAutoClearFixture = await page.evaluate(() => { @@ -3855,8 +4367,14 @@ try { clearedCoreResonance?.rows?.every((row) => row.selected === false && row.state === 'candidate') && !clearedCoreCampState?.selectedSortieUnitIds?.includes('zhang-fei') && clearedCoreCampState?.sortiePlanFeedback?.includes('핵심 공명조 자동 해제') && - clearedCoreResonanceSave.current?.sortieResonanceSelection === undefined, - `Expected removing a designated member to clear the persisted core pair and retain ordinary pursuit behavior: ${JSON.stringify({ clearedCoreCampState, clearedCoreResonance, save: clearedCoreResonanceSave.current?.sortieResonanceSelection })}` + clearedCoreCampState?.firstBattleCamaraderieMemory?.perk?.unlocked === true && + clearedCoreCampState.firstBattleCamaraderieMemory.perk.autoSelected === false && + clearedCoreCampState.firstBattleCamaraderieMemory.perk.candidateAvailable === false && + clearedCoreCampState.firstBattleCamaraderieMemory.perk.selected === false && + clearedCoreCampState.firstBattleCamaraderieMemory.perk.active === false && + clearedCoreResonanceSave.current?.sortieResonanceSelection === undefined && + clearedCoreResonanceSave.slot1?.sortieResonanceSelection === undefined, + `Expected removing Zhang Fei to clear the remembered core pair without converting the unlocked perk into an automatic selection: ${JSON.stringify({ clearedCoreCampState, clearedCoreResonance, save: clearedCoreResonanceSave.current?.sortieResonanceSelection })}` ); await page.evaluate((before) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); @@ -4053,7 +4571,17 @@ try { firstSortiePursuitRestored.planFeedback === firstSortiePursuitMutation.before?.planFeedback && firstSortiePursuitRestored.rosterScroll === firstSortiePursuitMutation.before?.rosterScroll && sameJsonValue(firstSortiePursuitRestored.deploymentPreview, firstCampDeploymentPreview) && - sameJsonValue(firstSortiePursuitRestored.pursuit?.activePairs, expectedFirstSortiePursuitPairs), + sameJsonValue(firstSortiePursuitRestored.pursuit?.activePairs, expectedFirstSortiePursuitPairs) && + firstSortiePursuitRestored.pursuit?.selectedCoreBondId === null && + firstSortiePursuitRestored.pursuit?.rows?.find( + (row) => row.bondId === 'liu-bei__zhang-fei' + )?.baseChainRate === 18 && + firstSortiePursuitRestored.pursuit?.rows?.find( + (row) => row.bondId === 'liu-bei__zhang-fei' + )?.bonusChainRate === 5 && + firstSortiePursuitRestored.pursuit?.rows?.find( + (row) => row.bondId === 'liu-bei__zhang-fei' + )?.effectiveChainRate === 23, `Expected the pursuit RC fixture to restore the exact live formation, roles, supplies, focus, feedback, scroll, and deployment: ${JSON.stringify(firstSortiePursuitRestored)}` ); assert( @@ -4104,8 +4632,14 @@ try { assert( launchCoreResonance?.activePairs?.[0]?.id === 'liu-bei__zhang-fei' && launchCoreResonance.activePairs[0].core === true && - launchCoreResonance.activePairs[0].chainRate === 18, - `Expected the lower legacy-rate pair to be promoted and prioritized before battle launch: ${JSON.stringify(launchCoreResonance)}` + launchCoreResonance.activePairs[0].baseChainRate === 8 && + launchCoreResonance.activePairs[0].chainRate === 23 && + launchCoreResonance?.rows?.[0]?.bondId === 'liu-bei__zhang-fei' && + launchCoreResonance.rows[0].baseChainRate === 18 && + launchCoreResonance.rows[0].bonusChainRate === 5 && + launchCoreResonance.rows[0].effectiveChainRate === 23 && + launchCoreResonance.rows[0].selected === true, + `Expected reselecting the remembered lower-level pair to restore its explicit 18% + 5%p launch rate: ${JSON.stringify(launchCoreResonance)}` ); await advanceSortiePrepStep(page, 'loadout'); await clickLegacyUi(page, 1116, 656); @@ -4167,7 +4701,10 @@ try { secondBattleProbe.coreSortieResonance.valid === true && secondBattleProbe.coreSortieResonance.active === true && secondBattleProbe.coreSortieResonance.level === 68 && - secondBattleProbe.coreSortieResonance.rate === 18 && + secondBattleProbe.coreSortieResonance.baseRate === 18 && + secondBattleProbe.coreSortieResonance.memoryBonus === 5 && + secondBattleProbe.coreSortieResonance.effectiveRate === 23 && + secondBattleProbe.coreSortieResonance.rate === 23 && sameJsonValue(secondBattleProbe.coreSortieResonance.unitIds, ['liu-bei', 'zhang-fei']) && sameJsonValue(secondBattleProbe.coreSortieResonance.stats, { attempts: 0, @@ -4176,7 +4713,50 @@ try { additionalDamage: 0 }) && sameJsonValue(secondBattleProbe.sortieResonance, secondBattleProbe.coreSortieResonance), - `Expected the camp designation to reach battle as one active 18% core resonance with clean statistics: ${JSON.stringify(secondBattleProbe.coreSortieResonance)}` + `Expected the manually designated remembered pair to reach battle at base 18% + memory 5%p = 23% with clean statistics: ${JSON.stringify(secondBattleProbe.coreSortieResonance)}` + ); + const nonRememberedCorePairProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const rawCampaign = window.localStorage.getItem('heros-web:campaign-state'); + if ( + !scene || + !rawCampaign || + typeof scene.refreshLaunchCamaraderieMemoryBonus !== 'function' + ) { + return { ready: false }; + } + const campaign = JSON.parse(rawCampaign); + const original = { + bondId: scene.launchSortieResonanceBondId, + memoryBondId: scene.launchCamaraderieMemoryBondId, + memoryBonus: scene.launchCamaraderieMemoryBonus + }; + try { + scene.launchSortieResonanceBondId = 'liu-bei__guan-yu'; + scene.refreshLaunchCamaraderieMemoryBonus(campaign); + const resonance = window.__HEROS_DEBUG__?.battle()?.coreSortieResonance; + return { + ready: true, + selectedBondId: resonance?.selectedBondId ?? null, + baseRate: resonance?.baseRate ?? null, + memoryBonus: resonance?.memoryBonus ?? null, + effectiveRate: resonance?.effectiveRate ?? null, + rate: resonance?.rate ?? null + }; + } finally { + scene.launchSortieResonanceBondId = original.bondId; + scene.launchCamaraderieMemoryBondId = original.memoryBondId; + scene.launchCamaraderieMemoryBonus = original.memoryBonus; + } + }); + assert( + nonRememberedCorePairProbe.ready === true && + nonRememberedCorePairProbe.selectedBondId === 'liu-bei__guan-yu' && + nonRememberedCorePairProbe.baseRate === 28 && + nonRememberedCorePairProbe.memoryBonus === 0 && + nonRememberedCorePairProbe.effectiveRate === 28 && + nonRememberedCorePairProbe.rate === 28, + `Expected a different valid core pair to keep its normal 28% rate without inheriting the Liu Bei/Zhang Fei +5%p memory: ${JSON.stringify(nonRememberedCorePairProbe)}` ); const secondBattleCorePriority = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); @@ -4191,7 +4771,12 @@ try { const touchedUnits = [attacker, corePartner, ordinaryPartner, target]; const original = { - units: touchedUnits.map((unit) => ({ unit, x: unit.x, y: unit.y })), + units: touchedUnits.map((unit) => ({ + unit, + x: unit.x, + y: unit.y, + direction: scene.unitViews.get(unit.id)?.direction ?? 'south' + })), attackIntents: scene.attackIntents.map((intent) => ({ ...intent })), actedUnitIds: new Set(scene.actedUnitIds), targetHp: target.hp, @@ -4213,7 +4798,7 @@ try { const preview = scene.combatPreview(attacker, target, 'attack'); const failedResolution = scene.resolveBondChainFollowUp(preview, true, false); const successfulResolution = scene.resolveBondChainFollowUp(preview, true, true); - const coreStats = window.__HEROS_DEBUG__?.battle()?.coreSortieResonance?.stats ?? null; + const coreResonance = window.__HEROS_DEBUG__?.battle()?.coreSortieResonance ?? null; const pursuitTriggers = scene.statsFor(attacker.id).sortieExtendedBondTriggers; return { ready: true, @@ -4233,19 +4818,33 @@ try { }, failedResolution: failedResolution ? { succeeded: failedResolution.attempt.succeeded, + rate: failedResolution.attempt.rate, coreResonance: failedResolution.attempt.coreResonance, hasResult: Boolean(failedResolution.result) } : null, successfulResolution: successfulResolution ? { succeeded: successfulResolution.attempt.succeeded, + rate: successfulResolution.attempt.rate, coreResonance: successfulResolution.attempt.coreResonance, damage: successfulResolution.result?.damage ?? 0 } : null, - coreStats, + coreResonance: coreResonance + ? { + selectedBondId: coreResonance.selectedBondId, + rate: coreResonance.rate, + baseRate: coreResonance.baseRate, + memoryBonus: coreResonance.memoryBonus, + effectiveRate: coreResonance.effectiveRate, + stats: coreResonance.stats + } + : null, pursuitTriggers }; } finally { - original.units.forEach(({ unit, x, y }) => Object.assign(unit, { x, y })); + original.units.forEach(({ unit, x, y, direction }) => { + Object.assign(unit, { x, y }); + scene.syncUnitMotion(unit, scene.unitViews.get(unit.id), direction); + }); target.hp = original.targetHp; scene.battleStats = original.battleStats; scene.attackIntents = original.attackIntents; @@ -4256,27 +4855,34 @@ try { secondBattleCorePriority.ready === true && secondBattleCorePriority.candidate?.bondId === 'liu-bei__zhang-fei' && secondBattleCorePriority.candidate.partnerId === 'zhang-fei' && - secondBattleCorePriority.candidate.rate === 18 && + secondBattleCorePriority.candidate.rate === 23 && secondBattleCorePriority.candidate.coreResonance === true && secondBattleCorePriority.candidate.label.includes('핵심 공명') && secondBattleCorePriority.preview?.bondId === 'liu-bei__zhang-fei' && secondBattleCorePriority.preview.partnerId === 'zhang-fei' && - secondBattleCorePriority.preview.rate === 18 && + secondBattleCorePriority.preview.rate === 23 && secondBattleCorePriority.preview.coreResonance === true && + secondBattleCorePriority.coreResonance?.selectedBondId === 'liu-bei__zhang-fei' && + secondBattleCorePriority.coreResonance.baseRate === 18 && + secondBattleCorePriority.coreResonance.memoryBonus === 5 && + secondBattleCorePriority.coreResonance.effectiveRate === 23 && + secondBattleCorePriority.coreResonance.rate === 23 && secondBattleCorePriority.failedResolution?.succeeded === false && + secondBattleCorePriority.failedResolution.rate === 23 && secondBattleCorePriority.failedResolution.coreResonance === true && secondBattleCorePriority.failedResolution.hasResult === false && secondBattleCorePriority.successfulResolution?.succeeded === true && + secondBattleCorePriority.successfulResolution.rate === 23 && secondBattleCorePriority.successfulResolution.coreResonance === true && secondBattleCorePriority.successfulResolution.damage > 0 && secondBattleCorePriority.pursuitTriggers === 1 && - sameJsonValue(secondBattleCorePriority.coreStats, { + sameJsonValue(secondBattleCorePriority.coreResonance.stats, { attempts: 2, successes: 1, failures: 1, additionalDamage: secondBattleCorePriority.successfulResolution.damage }), - `Expected the designated Lv68 pair to outrank Liu Bei's ordinary Lv72 pursuit candidate: ${JSON.stringify(secondBattleCorePriority)}` + `Expected forced failure/success to use exactly 23% and retain one non-stacking +5%p memory bonus over the 18% core rate: ${JSON.stringify(secondBattleCorePriority)}` ); assert( secondBattleProbe.openingBannerTexts?.some((text) => text.includes('세 형제 생존 중 공명 지원 거리 2칸')) && @@ -4416,13 +5022,17 @@ try { assert( roleSealResyncProbe?.changedCoreResonance?.selectedBondId === 'liu-bei__zhang-fei' && roleSealResyncProbe.changedCoreResonance.active === true && + roleSealResyncProbe.changedCoreResonance.baseRate === 18 && + roleSealResyncProbe.changedCoreResonance.memoryBonus === 5 && + roleSealResyncProbe.changedCoreResonance.effectiveRate === 23 && + roleSealResyncProbe.changedCoreResonance.rate === 23 && sameJsonValue(roleSealResyncProbe.changedCoreResonance.stats, { attempts: 2, successes: 1, failures: 1, additionalDamage: secondBattleCorePriority.successfulResolution.damage }), - `Expected battle save data—not the cleared in-memory or campaign fallback—to restore the core designation and totals: ${JSON.stringify(roleSealResyncProbe?.changedCoreResonance)}` + `Expected battle save data—not the cleared in-memory selection—to restore the core pair, totals, and one non-stacking 18% + 5%p rate: ${JSON.stringify(roleSealResyncProbe?.changedCoreResonance)}` ); assert( roleSealResyncProbe?.allReserve?.doctrineActive === true && @@ -4441,14 +5051,18 @@ try { assert( roleSealResyncProbe?.restoredCoreResonance?.selectedBondId === 'liu-bei__zhang-fei' && roleSealResyncProbe.restoredCoreResonance.active === true && - roleSealResyncProbe.restoredCoreResonance.rate === 18 && + roleSealResyncProbe.restoredCoreResonance.baseRate === 18 && + roleSealResyncProbe.restoredCoreResonance.memoryBonus === 5 && + roleSealResyncProbe.restoredCoreResonance.effectiveRate === 23 && + roleSealResyncProbe.restoredCoreResonance.rate === 23 && + roleSealResyncProbe.restoredCoreResonance.rate !== 28 && sameJsonValue(roleSealResyncProbe.restoredCoreResonance.stats, { attempts: 2, successes: 1, failures: 1, additionalDamage: secondBattleCorePriority.successfulResolution.damage }), - `Expected battle-slot reloads to preserve the selected core pair and its accumulated pursuit statistics: ${JSON.stringify(roleSealResyncProbe?.restoredCoreResonance)}` + `Expected battle-slot reloads to preserve 23% rather than double-applying memory to 28%, alongside accumulated pursuit statistics: ${JSON.stringify(roleSealResyncProbe?.restoredCoreResonance)}` ); await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); @@ -4462,20 +5076,20 @@ try { return { stats: resonance?.stats ?? null, resultSummary: resonance?.resultSummary ?? null, - summary: texts.find((text) => text.startsWith('핵심 공명 · 시도')) ?? null + summary: texts.find((text) => text.startsWith('핵심 공명 · 판정')) ?? null }; }); await page.screenshot({ path: `${screenshotDir}/rc-second-battle-core-resonance-result.png`, fullPage: true }); assert( secondBattleCoreResult.summary === - `핵심 공명 · 시도 2 · 성공 1 · 추가 피해 +${secondBattleCorePriority.successfulResolution.damage}` && + `핵심 공명 · 판정 23% (전우 기억 +5%p) · 시도 2 · 성공 1 · 추가 피해 +${secondBattleCorePriority.successfulResolution.damage}` && sameJsonValue(secondBattleCoreResult.stats, roleSealResyncProbe.restoredCoreResonance.stats) && secondBattleCoreResult.resultSummary?.visible === true && secondBattleCoreResult.resultSummary.text === secondBattleCoreResult.summary && isFiniteBounds(secondBattleCoreResult.resultSummary.bounds) && boundsInside(secondBattleCoreResult.resultSummary.bounds, logicalViewportBounds) && secondBattleCoreResult.resultSummary.bounds.y + secondBattleCoreResult.resultSummary.bounds.height <= 416 * legacyUiScale, - `Expected the victory report to surface persisted core-resonance attempts, successes, and damage: ${JSON.stringify(secondBattleCoreResult)}` + `Expected the victory report to close the remembered-pair feedback loop with the applied 23% rate, +5%p source, attempts, successes, and damage: ${JSON.stringify(secondBattleCoreResult)}` ); const requiredCasualtySaveBefore = await readCampaignSave(page); @@ -10121,6 +10735,60 @@ async function readFirstVictoryFollowupControlPoint(page, control, choiceId) { return probe; } +async function readCampDialogueControlPoint(page, control, dialogueId, choiceId) { + const probe = await page.evaluate(({ control, dialogueId, choiceId }) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp?.(); + const canvas = document.querySelector('canvas'); + const memory = state?.firstBattleCamaraderieMemory; + const dialogue = memory?.dialogue?.id === dialogueId + ? memory.dialogue + : state?.selectedDialogue?.id === dialogueId + ? state.selectedDialogue + : null; + const choice = dialogue?.choices?.find((candidate) => candidate.id === choiceId); + const bounds = control === 'row' ? dialogue?.rowBounds : choice?.bounds; + const interactiveObject = control === 'row' + ? scene?.campDialogueRowButtons?.[dialogueId] + : scene?.campDialogueChoiceButtons?.[`${dialogueId}:${choiceId}`]; + if ( + !scene || + !canvas || + !bounds || + !interactiveObject?.input?.enabled + ) { + return { + ready: false, + control, + dialogueId, + choiceId: choiceId ?? null, + bounds: bounds ?? null, + dialogue: dialogue ?? null + }; + } + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + control, + dialogueId, + choiceId: choiceId ?? null, + bounds, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, { control, dialogueId, choiceId }); + assert( + probe.ready === true && + Number.isFinite(probe.x) && + Number.isFinite(probe.y) && + boundsWithinFhdViewport(probe.bounds), + `Expected an interactive ${control} control for camp dialogue ${dialogueId}: ${JSON.stringify(probe)}` + ); + return probe; +} + async function readSortieTextControlPoint(page, label) { const probe = await page.evaluate((targetLabel) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index bcdbe80..727e9ec 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -24,6 +24,7 @@ const checks = [ 'scripts/verify-city-stay-data.mjs', 'scripts/verify-prologue-village-data.mjs', 'scripts/verify-first-battle-camp-followup.mjs', + 'scripts/verify-first-battle-camaraderie-memory.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/src/game/data/firstBattleCamaraderieMemory.ts b/src/game/data/firstBattleCamaraderieMemory.ts new file mode 100644 index 0000000..c860a5e --- /dev/null +++ b/src/game/data/firstBattleCamaraderieMemory.ts @@ -0,0 +1,341 @@ +import type { + CampaignBattleSettlement, + CampaignSortieCooperationSnapshot +} from '../state/campaignState'; + +export const firstBattleCamaraderieSourceBattleId = + 'first-battle-zhuo-commandery'; +export const firstBattleCamaraderieTargetBattleId = + 'second-battle-yellow-turban-pursuit'; +export const firstBattleCamaraderieDialogueId = + 'first-battle-camaraderie-memory'; +export const firstBattleCamaraderieCoreRateBonus = 5; + +export type SortieCooperationStats = { + attempts: number; + successes: number; + additionalDamage: number; +}; + +export type FirstBattleCamaraderieDialogueChoice = { + id: string; + label: string; + response: string; + rewardExp: number; +}; + +export type FirstBattleCamaraderieDialogue = { + id: typeof firstBattleCamaraderieDialogueId; + title: string; + unitIds: [string, string]; + bondId: string; + rewardExp: number; + lines: [string, string, string]; + choices: [ + FirstBattleCamaraderieDialogueChoice, + FirstBattleCamaraderieDialogueChoice + ]; +}; + +export type FirstBattleCamaraderieMemory = { + sourceBattleId: typeof firstBattleCamaraderieSourceBattleId; + targetBattleId: typeof firstBattleCamaraderieTargetBattleId; + bondId: string; + unitIds: [string, string]; + unitNames: [string, string]; + attempts: number; + successes: number; + additionalDamage: number; + rememberedOutcome: 'success' | 'attempted'; + completed: boolean; + bonusRate: typeof firstBattleCamaraderieCoreRateBonus; + summary: string; + dialogue: FirstBattleCamaraderieDialogue; +}; + +export type FirstBattleCamaraderieCampaignState = { + battleHistory?: Partial>; + completedCampDialogues?: readonly string[]; + roster?: readonly { + id: string; + name: string; + }[]; +}; + +type CooperationBond = { + id: string; + unitIds: readonly string[]; +}; + +export function selectDominantSortieCooperationSnapshot(options: { + statsByBond?: Readonly< + Record + >; + bonds: readonly CooperationBond[]; +}): CampaignSortieCooperationSnapshot | undefined { + const bondById = new Map( + options.bonds + .filter((bond) => ( + validKey(bond.id) && + bond.unitIds.length === 2 && + validKey(bond.unitIds[0]) && + validKey(bond.unitIds[1]) && + bond.unitIds[0] !== bond.unitIds[1] + )) + .map((bond) => [bond.id, bond]) + ); + const candidates = Object.entries(options.statsByBond ?? {}) + .flatMap(([bondId, stats]) => { + const bond = bondById.get(bondId); + const normalized = normalizeStats(stats); + if (!bond || !normalized || normalized.attempts <= 0) { + return []; + } + return [{ + version: 1, + bondId, + unitIds: [bond.unitIds[0], bond.unitIds[1]], + ...normalized + }]; + }) + .sort(compareCooperationSnapshots); + + return candidates[0] + ? cloneCooperationSnapshot(candidates[0]) + : undefined; +} + +export function resolveFirstBattleCamaraderieMemory( + campaign?: FirstBattleCamaraderieCampaignState | null +): FirstBattleCamaraderieMemory | undefined { + const settlement = + campaign?.battleHistory?.[firstBattleCamaraderieSourceBattleId]; + if ( + !settlement || + settlement.battleId !== firstBattleCamaraderieSourceBattleId || + settlement.outcome !== 'victory' + ) { + return undefined; + } + const snapshot = validCampaignSnapshot(settlement.sortieCooperation); + if (!snapshot) { + return undefined; + } + + const unitNamesById = new Map(); + campaign?.roster?.forEach((unit) => { + if (validKey(unit.id) && validDisplayName(unit.name)) { + unitNamesById.set(unit.id, unit.name.trim()); + } + }); + (settlement.units ?? []).forEach((unit) => { + if (validKey(unit.unitId) && validDisplayName(unit.name)) { + unitNamesById.set(unit.unitId, unit.name.trim()); + } + }); + const unitNames: [string, string] = [ + unitNamesById.get(snapshot.unitIds[0]) ?? snapshot.unitIds[0], + unitNamesById.get(snapshot.unitIds[1]) ?? snapshot.unitIds[1] + ]; + const rememberedOutcome = + snapshot.successes > 0 ? 'success' : 'attempted'; + const dialogue = createCamaraderieDialogue( + snapshot, + unitNames, + rememberedOutcome + ); + + return { + sourceBattleId: firstBattleCamaraderieSourceBattleId, + targetBattleId: firstBattleCamaraderieTargetBattleId, + bondId: snapshot.bondId, + unitIds: [...snapshot.unitIds], + unitNames, + attempts: snapshot.attempts, + successes: snapshot.successes, + additionalDamage: snapshot.additionalDamage, + rememberedOutcome, + completed: Boolean( + campaign?.completedCampDialogues?.includes( + firstBattleCamaraderieDialogueId + ) + ), + bonusRate: firstBattleCamaraderieCoreRateBonus, + summary: rememberedOutcome === 'success' + ? `${unitNames.join(' · ')} 협공 ${snapshot.successes}/${snapshot.attempts}회 · 추가 피해 ${snapshot.additionalDamage}` + : `${unitNames.join(' · ')} 협공 시도 ${snapshot.attempts}회 · 다음 출전에서 호흡 보완`, + dialogue + }; +} + +export function firstBattleCamaraderieBonusFor(options: { + campaign?: FirstBattleCamaraderieCampaignState | null; + battleId?: string; + coreBondId?: string; +}) { + const memory = resolveFirstBattleCamaraderieMemory(options.campaign); + return ( + memory?.completed && + options.battleId === firstBattleCamaraderieTargetBattleId && + options.coreBondId === memory.bondId + ) + ? firstBattleCamaraderieCoreRateBonus + : 0; +} + +function compareCooperationSnapshots( + left: CampaignSortieCooperationSnapshot, + right: CampaignSortieCooperationSnapshot +) { + return ( + Number(right.successes > 0) - Number(left.successes > 0) || + right.successes - left.successes || + right.additionalDamage - left.additionalDamage || + right.attempts - left.attempts || + stableTextCompare(left.bondId, right.bondId) + ); +} + +function stableTextCompare(left: string, right: string) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function normalizeStats( + stats?: SortieCooperationStats +): SortieCooperationStats | undefined { + if (!stats || typeof stats !== 'object') { + return undefined; + } + const attempts = safeNonNegativeInteger(stats.attempts); + if (attempts <= 0) { + return undefined; + } + const successes = Math.min( + attempts, + safeNonNegativeInteger(stats.successes) + ); + return { + attempts, + successes, + additionalDamage: successes > 0 + ? safeNonNegativeInteger(stats.additionalDamage) + : 0 + }; +} + +function validCampaignSnapshot( + snapshot?: CampaignSortieCooperationSnapshot +): CampaignSortieCooperationSnapshot | undefined { + if ( + !snapshot || + snapshot.version !== 1 || + !validKey(snapshot.bondId) || + !Array.isArray(snapshot.unitIds) || + snapshot.unitIds.length !== 2 || + !validKey(snapshot.unitIds[0]) || + !validKey(snapshot.unitIds[1]) || + snapshot.unitIds[0] === snapshot.unitIds[1] + ) { + return undefined; + } + const stats = normalizeStats(snapshot); + if (!stats) { + return undefined; + } + return { + version: 1, + bondId: snapshot.bondId, + unitIds: [...snapshot.unitIds], + ...stats + }; +} + +function cloneCooperationSnapshot( + snapshot: CampaignSortieCooperationSnapshot +): CampaignSortieCooperationSnapshot { + return { + ...snapshot, + unitIds: [...snapshot.unitIds] + }; +} + +function createCamaraderieDialogue( + snapshot: CampaignSortieCooperationSnapshot, + unitNames: [string, string], + rememberedOutcome: FirstBattleCamaraderieMemory['rememberedOutcome'] +): FirstBattleCamaraderieDialogue { + const [firstName, secondName] = unitNames; + const lines: [string, string, string] = rememberedOutcome === 'success' + ? [ + `${firstName}: 지난 싸움에서 네가 틈을 이어 준 덕에 적진을 흔들 수 있었어.`, + `${secondName}: 서로의 신호를 알아본 순간이었습니다. 다음에도 같은 호흡을 잇겠습니다.`, + `${firstName}: 좋아. 황건 추격에서도 우리가 직접 핵심 공명조를 정해 이 합을 시험하자.` + ] + : [ + `${firstName}: 지난 싸움에서는 신호가 한 박자 어긋나 합을 끝까지 잇지 못했어.`, + `${secondName}: 그래도 서로가 움직이려던 순간은 보았습니다. 다음에는 놓치지 않겠습니다.`, + `${firstName}: 황건 추격 전에 신호를 맞추자. 핵심 공명조로 정한다면 이번에는 끝까지 잇는다.` + ]; + const choices: FirstBattleCamaraderieDialogue['choices'] = + rememberedOutcome === 'success' + ? [ + { + id: 'trust-remembered-rhythm', + label: '그 호흡을 다시 믿는다', + response: `두 사람은 첫 승리에서 맞춘 신호를 다시 확인했다. 다음 추격에서 같은 핵심 공명조를 직접 정하면 협공 확률 +${firstBattleCamaraderieCoreRateBonus}%p가 열린다.`, + rewardExp: 4 + }, + { + id: 'review-successful-signals', + label: '성공한 신호를 되짚는다', + response: `두 사람은 ${snapshot.successes}번 이어진 협공을 복기했다. 다음 추격에서 같은 핵심 공명조를 직접 정하면 협공 확률 +${firstBattleCamaraderieCoreRateBonus}%p가 열린다.`, + rewardExp: 2 + } + ] + : [ + { + id: 'correct-missed-timing', + label: '엇갈린 순간을 바로잡는다', + response: `두 사람은 ${snapshot.attempts}번의 시도를 되짚었다. 다음 추격에서 같은 핵심 공명조를 직접 정하면 협공 확률 +${firstBattleCamaraderieCoreRateBonus}%p가 열린다.`, + rewardExp: 4 + }, + { + id: 'agree-on-clear-signal', + label: '서로의 신호를 정한다', + response: `두 사람은 추격전의 신호를 정했다. 다음 추격에서 같은 핵심 공명조를 직접 정하면 협공 확률 +${firstBattleCamaraderieCoreRateBonus}%p가 열린다.`, + rewardExp: 2 + } + ]; + + return { + id: firstBattleCamaraderieDialogueId, + title: rememberedOutcome === 'success' + ? `${firstName} · ${secondName}, 이어진 합` + : `${firstName} · ${secondName}, 엇갈린 신호`, + unitIds: [...snapshot.unitIds], + bondId: snapshot.bondId, + rewardExp: 8, + lines, + choices + }; +} + +function safeNonNegativeInteger(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + return Math.min(999999, Math.max(0, Math.floor(value))); +} + +function validKey(value: unknown): value is string { + return ( + typeof value === 'string' && + value.trim() === value && + value.length > 0 && + value.length <= 96 + ); +} + +function validDisplayName(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index d49a01f..deb93ea 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -120,6 +120,10 @@ import { firstBattleVolunteerPromiseEventKey, volunteerPromiseLineForBattle } from '../data/firstBattleNarrativeMemory'; +import { + firstBattleCamaraderieBonusFor, + selectDominantSortieCooperationSnapshot +} from '../data/firstBattleCamaraderieMemory'; import { evaluateSortieOrder, evaluateSortieOrderProgress, @@ -154,6 +158,7 @@ import { type CampaignTutorialId, type CampaignSortieItemAssignments, type CampaignSortieOrderResultSnapshot, + type CampaignSortieCooperationSnapshot, type CampaignSortiePerformanceSnapshot, type CampaignSortiePresetId, type CampaignSortieRecommendationSnapshot, @@ -1697,6 +1702,12 @@ type BondState = BattleBond & { battleExp: number; }; +type SortieCooperationStats = { + attempts: number; + successes: number; + additionalDamage: number; +}; + type BondCombatBonus = { damageBonus: number; chainRate: number; @@ -3887,6 +3898,9 @@ export class BattleScene extends Phaser.Scene { private coreResonancePursuitSuccesses = 0; private coreResonancePursuitFailures = 0; private coreResonancePursuitDamage = 0; + private cooperationStatsByBond = new Map(); + private launchCamaraderieMemoryBondId?: string; + private launchCamaraderieMemoryBonus = 0; constructor() { super('BattleScene'); @@ -4017,6 +4031,9 @@ export class BattleScene extends Phaser.Scene { data?.sortieRecommendation ?? campaign.sortieRecommendationSelection ); this.resetCoreResonancePursuitStats(); + this.resetSortieCooperationStats(); + this.launchCamaraderieMemoryBondId = undefined; + this.launchCamaraderieMemoryBonus = 0; } create() { @@ -4076,7 +4093,9 @@ export class BattleScene extends Phaser.Scene { this.launchSortieResonanceBondId = this.validatedLaunchSortieResonanceBondId( this.launchSortieResonanceBondId ); + this.refreshLaunchCamaraderieMemoryBonus(campaign); this.resetCoreResonancePursuitStats(); + this.resetSortieCooperationStats(); this.itemStocks = this.createItemStocks(campaign); this.battleBuffs.clear(); this.battleStatuses.clear(); @@ -4838,6 +4857,17 @@ export class BattleScene extends Phaser.Scene { return bond.unitIds.every((unitId) => deployedAllies.has(unitId)) ? bond.id : undefined; } + private refreshLaunchCamaraderieMemoryBonus(campaign: CampaignState) { + const coreBondId = this.launchSortieResonanceBondId; + const bonus = firstBattleCamaraderieBonusFor({ + campaign, + battleId: battleScenario.id, + coreBondId + }); + this.launchCamaraderieMemoryBondId = bonus > 0 ? coreBondId : undefined; + this.launchCamaraderieMemoryBonus = bonus; + } + private resetCoreResonancePursuitStats() { this.coreResonancePursuitAttempts = 0; this.coreResonancePursuitSuccesses = 0; @@ -4865,6 +4895,70 @@ export class BattleScene extends Phaser.Scene { this.coreResonancePursuitDamage = stats.additionalDamage; } + private resetSortieCooperationStats() { + this.cooperationStatsByBond.clear(); + } + + private sortieCooperationStatsRecord() { + return Object.fromEntries( + Array.from(this.cooperationStatsByBond.entries()) + .filter(([bondId]) => this.bondStates.has(bondId)) + .sort(([leftBondId], [rightBondId]) => leftBondId.localeCompare(rightBondId)) + .map(([bondId, stats]) => [bondId, { ...stats }]) + ); + } + + private dominantSortieCooperationSnapshot(): CampaignSortieCooperationSnapshot | undefined { + return selectDominantSortieCooperationSnapshot({ + statsByBond: this.sortieCooperationStatsRecord(), + bonds: Array.from(this.bondStates.values()).map((bond) => ({ + id: bond.id, + unitIds: [...bond.unitIds] as [string, string] + })) + }); + } + + private restoreSortieCooperationStats( + statsByBond?: BattleSaveState['cooperationStatsByBond'] + ) { + this.resetSortieCooperationStats(); + Object.entries(statsByBond ?? {}) + .filter(([bondId]) => this.bondStates.has(bondId)) + .sort(([leftBondId], [rightBondId]) => leftBondId.localeCompare(rightBondId)) + .forEach(([bondId, stats]) => { + this.cooperationStatsByBond.set(bondId, { ...stats }); + }); + } + + private recordSortieCooperationAttempt(bondId: string) { + const normalizedBondId = bondId.trim(); + if (!normalizedBondId || !this.bondStates.has(normalizedBondId)) { + return; + } + const current = this.cooperationStatsByBond.get(normalizedBondId) ?? { + attempts: 0, + successes: 0, + additionalDamage: 0 + }; + this.cooperationStatsByBond.set(normalizedBondId, { + ...current, + attempts: current.attempts + 1 + }); + } + + private recordSortieCooperationSuccess(bondId: string, additionalDamage: number) { + const normalizedBondId = bondId.trim(); + const current = this.cooperationStatsByBond.get(normalizedBondId); + if (!normalizedBondId || !current) { + return; + } + this.cooperationStatsByBond.set(normalizedBondId, { + attempts: current.attempts, + successes: current.successes + 1, + additionalDamage: current.additionalDamage + Math.max(0, additionalDamage) + }); + } + private effectiveSortieUnitIds(campaign?: CampaignState) { const configured = this.launchSortieUnitIds.length > 0 ? this.launchSortieUnitIds @@ -13492,10 +13586,17 @@ export class BattleScene extends Phaser.Scene { } if (this.launchSortieResonanceBondId) { + const selectedCoreBond = this.bondStates.get(this.launchSortieResonanceBondId); + const selectedCoreRate = selectedCoreBond + ? this.bondChainRateBreakdown(selectedCoreBond) + : { baseRate: 0, memoryBonus: 0, effectiveRate: 0 }; + const memoryRateSummary = selectedCoreRate.memoryBonus > 0 + ? ` · 판정 ${selectedCoreRate.effectiveRate}% (전우 기억 +${selectedCoreRate.memoryBonus}%p)` + : ''; const coreResonanceSummary = this.trackResultObject(this.add.text( left + panelWidth - 44, top + 372, - `핵심 공명 · 시도 ${this.coreResonancePursuitAttempts} · 성공 ${this.coreResonancePursuitSuccesses} · 추가 피해 +${this.coreResonancePursuitDamage}`, + `핵심 공명${memoryRateSummary} · 시도 ${this.coreResonancePursuitAttempts} · 성공 ${this.coreResonancePursuitSuccesses} · 추가 피해 +${this.coreResonancePursuitDamage}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', @@ -14048,6 +14149,7 @@ export class BattleScene extends Phaser.Scene { sortieRecommendation: this.launchSortieRecommendation ? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation) : undefined, + sortieCooperation: this.dominantSortieCooperationSnapshot(), completedCampDialogues: [], completedCampVisits: [], createdAt: new Date().toISOString() @@ -16483,6 +16585,7 @@ export class BattleScene extends Phaser.Scene { succeeded, coreResonance: preview.bondChainCoreResonance }; + this.recordSortieCooperationAttempt(attempt.bondId); if (attempt.coreResonance) { this.coreResonancePursuitAttempts += 1; } @@ -16503,6 +16606,7 @@ export class BattleScene extends Phaser.Scene { this.coreResonancePursuitSuccesses += 1; this.coreResonancePursuitDamage += damage; } + this.recordSortieCooperationSuccess(attempt.bondId, damage); this.statsFor(preview.attacker.id).sortieExtendedBondTriggers += 1; return { @@ -19140,6 +19244,7 @@ export class BattleScene extends Phaser.Scene { coreResonanceStats: this.launchSortieResonanceBondId ? { ...this.coreResonancePursuitStatsSnapshot() } : undefined, + cooperationStatsByBond: this.sortieCooperationStatsRecord(), savedAt: new Date().toISOString(), turnNumber: this.turnNumber, activeFaction: this.activeFaction, @@ -19218,6 +19323,7 @@ export class BattleScene extends Phaser.Scene { ); this.launchSortieRecommendation = this.normalizeLaunchSortieRecommendation(state.sortieRecommendation); this.resetCoreResonancePursuitStats(); + this.resetSortieCooperationStats(); this.turnNumber = Math.max(1, state.turnNumber || 1); this.activeFaction = state.activeFaction === 'enemy' ? 'enemy' : 'ally'; this.rosterTab = state.rosterTab === 'enemy' ? 'enemy' : 'ally'; @@ -19280,9 +19386,11 @@ export class BattleScene extends Phaser.Scene { this.launchSortieResonanceBondId = this.validatedLaunchSortieResonanceBondId( this.launchSortieResonanceBondId ); + this.refreshLaunchCamaraderieMemoryBonus(getCampaignState()); if (this.launchSortieResonanceBondId) { this.restoreCoreResonancePursuitStats(state.coreResonanceStats); } + this.restoreSortieCooperationStats(state.cooperationStatsByBond); const cameraFocus = battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0) ?? battleUnits[0]; if (cameraFocus) { @@ -24058,12 +24166,36 @@ export class BattleScene extends Phaser.Scene { return bond.id === this.launchSortieResonanceBondId && bond.level >= coreSortieResonanceMinLevel; } - private bondChainRateForBond(bond: Pick) { + private baseBondChainRateForBond(bond: Pick) { return this.isCoreSortieResonanceBond(bond) ? coreSortieResonanceChainRateForLevel(bond.level) : sortieBondBonusForLevel(bond.level).chainRate; } + private camaraderieMemoryBonusForBond(bond: Pick) { + if ( + !this.isCoreSortieResonanceBond(bond) || + bond.id !== this.launchCamaraderieMemoryBondId + ) { + return 0; + } + return this.launchCamaraderieMemoryBonus; + } + + private bondChainRateBreakdown(bond: Pick) { + const baseRate = this.baseBondChainRateForBond(bond); + const memoryBonus = this.camaraderieMemoryBonusForBond(bond); + return { + baseRate, + memoryBonus, + effectiveRate: Phaser.Math.Clamp(baseRate + memoryBonus, 0, 100) + }; + } + + private bondChainRateForBond(bond: Pick) { + return this.bondChainRateBreakdown(bond).effectiveRate; + } + private bondChainPartnerEligible( attacker: UnitData, partner: UnitData, @@ -28387,6 +28519,11 @@ export class BattleScene extends Phaser.Scene { private coreSortieResonanceDebugState() { const selectedBondId = this.launchSortieResonanceBondId; const bond = selectedBondId ? this.bondStates.get(selectedBondId) : undefined; + const rate = bond + ? this.bondChainRateBreakdown(bond) + : { baseRate: 0, memoryBonus: 0, effectiveRate: 0 }; + const cooperationStatsByBond = this.sortieCooperationStatsRecord(); + const cooperationSnapshot = this.dominantSortieCooperationSnapshot(); const active = Boolean( bond && this.isCoreSortieResonanceBond(bond) && @@ -28404,13 +28541,25 @@ export class BattleScene extends Phaser.Scene { unitIds: bond ? [...bond.unitIds] : [], unitNames: bond ? bond.unitIds.map((unitId) => this.unitName(unitId)) : [], level: bond?.level ?? null, - rate: bond ? this.bondChainRateForBond(bond) : 0, + rate: rate.effectiveRate, + baseRate: rate.baseRate, + memoryBonus: rate.memoryBonus, + effectiveRate: rate.effectiveRate, stats: { attempts: this.coreResonancePursuitAttempts, successes: this.coreResonancePursuitSuccesses, failures: this.coreResonancePursuitFailures, additionalDamage: this.coreResonancePursuitDamage }, + cooperation: { + statsByBond: cooperationStatsByBond, + dominantSnapshot: cooperationSnapshot + ? { + ...cooperationSnapshot, + unitIds: [...cooperationSnapshot.unitIds] + } + : null + }, resultSummary: this.resultCoreResonanceSummaryText?.active ? { visible: this.resultCoreResonanceSummaryText.visible, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 646a0e3..e94227b 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -28,6 +28,12 @@ import { resolveFirstBattleCampFollowup, type FirstBattleCampFollowup } from '../data/firstBattleCampFollowup'; +import { + firstBattleCamaraderieBonusFor, + firstBattleCamaraderieCoreRateBonus, + resolveFirstBattleCamaraderieMemory, + type FirstBattleCamaraderieMemory +} from '../data/firstBattleCamaraderieMemory'; import { findCityStayAfterBattle, type CityEquipmentOffer, @@ -605,6 +611,10 @@ type SortiePursuitPanelView = { rows: { bondId: string; level: number; + baseChainRate: number; + bonusChainRate: number; + effectiveChainRate: number; + camaraderieRemembered: boolean; chainRate: number; selected: boolean; state: 'selected' | 'candidate'; @@ -615,7 +625,10 @@ type SortiePursuitPanelView = { }; type SortieCoreResonanceCandidate = SortieSynergyActiveBond & { + baseCoreChainRate: number; + camaraderieBonusRate: number; coreChainRate: number; + camaraderieRemembered: boolean; selected: boolean; }; @@ -12807,6 +12820,24 @@ export class CampScene extends Phaser.Scene { return resolution.source === 'campaign' ? resolution : undefined; } + private firstBattleCamaraderieMemory(): FirstBattleCamaraderieMemory | undefined { + return resolveFirstBattleCamaraderieMemory(this.campaign); + } + + private firstBattleCamaraderieDialogue(): CampDialogue | undefined { + if (!this.isFirstSortiePrepSlice()) { + return undefined; + } + const memory = this.firstBattleCamaraderieMemory(); + if (!memory) { + return undefined; + } + return { + ...memory.dialogue, + availableAfterBattleIds: [memory.sourceBattleId] + }; + } + private campDialogueWithFirstBattleFollowup(dialogue: CampDialogue) { const followup = this.firstBattleCampFollowup(); if (!followup || dialogue.id !== followup.targetDialogueId) { @@ -12827,14 +12858,26 @@ export class CampScene extends Phaser.Scene { ); } - private availableCampDialogues() { + private hasPendingFirstBattleCamaraderieMemory() { + const dialogue = this.firstBattleCamaraderieDialogue(); + return Boolean( + dialogue && + !this.completedCampDialogues().includes(dialogue.id) + ); + } + + private availableCampDialogues(): CampDialogue[] { const battleId = this.currentCampBattleId(); const battleDialogues = campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(battleId)); const dialogues = this.filterCampEventsByStep(battleDialogues); const available = dialogues.length > 0 ? dialogues : campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(defaultBattleScenario.id)); - return available.map((dialogue) => this.campDialogueWithFirstBattleFollowup(dialogue)); + const resolved = available.map((dialogue) => this.campDialogueWithFirstBattleFollowup(dialogue)); + const camaraderieDialogue = this.firstBattleCamaraderieDialogue(); + return camaraderieDialogue && !resolved.some((dialogue) => dialogue.id === camaraderieDialogue.id) + ? [...resolved, camaraderieDialogue] + : resolved; } private completedAvailableDialogues() { @@ -13824,15 +13867,20 @@ export class CampScene extends Phaser.Scene { const pendingCategories = new Set(getPendingCampaignVictoryRewardCategories()); this.tabButtons.forEach(({ tab, bg, text, indicator, newBadge, hovered }) => { const active = this.activeTab === tab; + const pendingFirstBattleFollowup = tab === 'dialogue' && this.hasPendingFirstBattleCampFollowup(); + const pendingCamaraderieMemory = tab === 'dialogue' && this.hasPendingFirstBattleCamaraderieMemory(); bg.setFillStyle(active ? 0x2b4052 : hovered ? 0x243747 : 0x182431, active || hovered ? 0.98 : 0.94); bg.setStrokeStyle(active || hovered ? 2 : 1, active || hovered ? accentColor : palette.blue, active ? 0.96 : hovered ? 0.82 : 0.62); text.setColor(active || hovered ? '#fff2b8' : '#f2e3bf'); indicator.setFillStyle(accentColor, 1); indicator.setVisible(active || hovered); indicator.setAlpha(active ? 1 : 0.66); + if (tab === 'dialogue' && newBadge) { + newBadge.setText(pendingFirstBattleFollowup ? '회고' : pendingCamaraderieMemory ? '전우' : '회고'); + } newBadge?.setVisible(Boolean( (tab === 'city' && this.hasPendingCityStayActions()) || - (tab === 'dialogue' && this.hasPendingFirstBattleCampFollowup()) || + (tab === 'dialogue' && (pendingFirstBattleFollowup || pendingCamaraderieMemory)) || (tab === 'supplies' && pendingCategories.has('supplies')) || (tab === 'equipment' && pendingCategories.has('equipment')) )); @@ -15194,11 +15242,15 @@ export class CampScene extends Phaser.Scene { this.setCampaignSortieCoreSelectorVisible(true); view.background.setStrokeStyle(selectedCore ? 2 : 1, selectedCore ? palette.green : palette.gold, selectedCore ? 0.78 : 0.62); view.title.setText('핵심 공명조'); - view.summary.setText(selectedCore ? `지정 ${selectedCore.coreChainRate}% · 후보 ${coreCandidates.length}조` : `미지정 · 후보 ${coreCandidates.length}조`); + view.summary.setText( + selectedCore + ? `지정 ${selectedCore.coreChainRate}%${selectedCore.camaraderieRemembered ? ` · 기억 +${selectedCore.camaraderieBonusRate}%p` : ''} · 후보 ${coreCandidates.length}조` + : `미지정 · 후보 ${coreCandidates.length}조` + ); view.headline .setText( selectedCore - ? `${selectedCore.unitNames[0]}↔${selectedCore.unitNames[1]} · Lv${selectedCore.level} 핵심 추격 준비` + ? `${selectedCore.unitNames[0]}↔${selectedCore.unitNames[1]} · Lv${selectedCore.level} 핵심 추격 ${selectedCore.baseCoreChainRate}%${selectedCore.camaraderieRemembered ? ` + 전우 기억 ${selectedCore.camaraderieBonusRate}%p` : ''}` : coreCandidates.length > 0 ? '아래 인연 칩을 눌러 이번 전투의 핵심 한 조를 정하십시오.' : `Lv${coreSortieResonanceMinLevel} 이상 인연 조원을 함께 편성하면 후보가 열립니다.` @@ -15208,7 +15260,11 @@ export class CampScene extends Phaser.Scene { .setText(this.compactText(this.sortiePlanFeedback || (selectedCore ? '다시 누르면 지정을 해제합니다.' : '정확히 한 조만 지정되며 다른 조를 누르면 즉시 교체됩니다.'), 68)) .setColor(this.sortiePlanFeedback || selectedCore ? '#a8ffd0' : '#d4dce6'); view.detail - .setText(`Lv${coreSortieResonanceMinLevel}+ · 핵심 추격 8/18/28% · 일반 추격 확률은 그대로 유지`) + .setText( + coreCandidates.some((candidate) => candidate.camaraderieRemembered) + ? `전우 기억한 같은 조 직접 지정 시 +${firstBattleCamaraderieCoreRateBonus}%p · 재클릭 해제 · 일반 추격 유지` + : `Lv${coreSortieResonanceMinLevel}+ · 핵심 추격 8/18/28% · 일반 추격 확률은 그대로 유지` + ) .setColor('#9fb0bf'); return; } @@ -15865,10 +15921,16 @@ export class CampScene extends Phaser.Scene { const rows: SortiePursuitPanelView['rows'] = visibleCandidates.map((candidate, index) => { const chipX = x + index * (chipWidth + chipGap); const selected = candidate.selected; - const chipText = `${selected ? '핵심' : '지정'} ${candidate.coreChainRate}% ${candidate.unitNames[0]}↔${candidate.unitNames[1]}`; + const rateText = candidate.camaraderieRemembered + ? `${candidate.baseCoreChainRate}→${candidate.coreChainRate}%` + : `${candidate.coreChainRate}%`; + const chipText = `${selected ? '핵심' : '지정'} ${rateText} · ${candidate.unitNames[0]}↔${candidate.unitNames[1]}`; + const chipLabel = candidate.camaraderieRemembered + ? `${this.compactText(chipText, 20)}\n전우 기억 +${candidate.camaraderieBonusRate}%p` + : this.compactText(chipText, chipWidth >= 140 ? 18 : 15); const fill = selected ? 0x493719 : 0x173329; const stroke = selected ? palette.gold : palette.green; - const background = this.trackSortie(this.add.rectangle(chipX, y, chipWidth, 22, fill, 0.98)); + const background = this.trackSortie(this.add.rectangle(chipX, y, chipWidth, 32, fill, 0.98)); background.setOrigin(0); background.setDepth(depth); background.setStrokeStyle(selected ? 2 : 1, stroke, selected ? 0.96 : 0.68); @@ -15883,9 +15945,13 @@ export class CampScene extends Phaser.Scene { const label = this.trackSortie( this.add.text( chipX + chipWidth / 2, - y + 11, - this.compactText(chipText, chipWidth >= 140 ? 18 : 15), - this.textStyle(9, selected ? '#ffdf7b' : '#a8ffd0', true) + y + 16, + chipLabel, + { + ...this.textStyle(candidate.camaraderieRemembered ? 8 : 9, selected ? '#ffdf7b' : '#a8ffd0', true), + align: 'center', + lineSpacing: 0 + } ) ); label.setOrigin(0.5); @@ -15893,6 +15959,10 @@ export class CampScene extends Phaser.Scene { return { bondId: candidate.id, level: candidate.level, + baseChainRate: candidate.baseCoreChainRate, + bonusChainRate: candidate.camaraderieBonusRate, + effectiveChainRate: candidate.coreChainRate, + camaraderieRemembered: candidate.camaraderieRemembered, chainRate: candidate.coreChainRate, selected, state: selected ? 'selected' as const : 'candidate' as const, @@ -15955,7 +16025,7 @@ export class CampScene extends Phaser.Scene { this.add.text( x + 18, y + 9, - `핵심 공명조 · ${selectedCore ? '지정' : '미지정'} · 후보 ${coreCandidates.length}조`, + `핵심 공명조 · ${selectedCore ? `${selectedCore.coreChainRate}% 지정` : '미지정'} · 후보 ${coreCandidates.length}조`, this.textStyle(14, '#f2e3bf', true) ) ); @@ -15971,7 +16041,7 @@ export class CampScene extends Phaser.Scene { width - 36, depth + 1, x + width - 84, - y + 60 + y + 72 ); if (pursuitPageView.rows.length === 0) { this.trackSortie( @@ -15979,7 +16049,14 @@ export class CampScene extends Phaser.Scene { ).setDepth(depth + 1); } const ruleText = this.trackSortie( - this.add.text(x + 18, y + 62, `Lv${coreSortieResonanceMinLevel}+ 한 조 · 핵심 추격 8/18/28% · 재클릭 해제`, this.textStyle(9, '#d4dce6', true)) + this.add.text( + x + 18, + y + 72, + coreCandidates.some((candidate) => candidate.camaraderieRemembered) + ? `전우 기억한 조 직접 지정 · 핵심 추격 +${firstBattleCamaraderieCoreRateBonus}%p · 재클릭 해제` + : `Lv${coreSortieResonanceMinLevel}+ 한 조 · 핵심 추격 8/18/28% · 재클릭 해제`, + this.textStyle(9, '#d4dce6', true) + ) ); ruleText.setDepth(depth + 1); const missingRoleLabels: Record<(typeof coreSortieSynergyRoles)[number], string> = { @@ -15994,7 +16071,7 @@ export class CampScene extends Phaser.Scene { : '삼재진 대기 · 세 형제의 역할을 나누십시오.'; const bottomLine = this.sortiePlanFeedback ? `${formationLine} · ${this.sortiePlanFeedback}` : formationLine; this.trackSortie( - this.add.text(x + 18, y + 88, this.compactText(bottomLine, 47), this.textStyle(11, synergy.firstPursuitTrinityConfigured ? '#a8ffd0' : '#ffdf7b', true)) + this.add.text(x + 18, y + 96, this.compactText(bottomLine, 47), this.textStyle(11, synergy.firstPursuitTrinityConfigured ? '#a8ffd0' : '#ffdf7b', true)) ).setDepth(depth + 1); this.sortiePursuitPanelView = { background: bg, summaryText, ruleText, ...pursuitPageView }; } @@ -17252,13 +17329,27 @@ export class CampScene extends Phaser.Scene { const seenPairs = new Set(); return snapshot.activeBonds .filter((bond) => bond.level >= coreSortieResonanceMinLevel) - .map((bond) => ({ - ...bond, - coreChainRate: coreSortieResonanceChainRateForLevel(bond.level), - selected: bond.id === selectedBondId - })) + .map((bond) => { + const baseCoreChainRate = coreSortieResonanceChainRateForLevel(bond.level); + const camaraderieBonusRate = scenario + ? firstBattleCamaraderieBonusFor({ + campaign: this.campaign, + battleId: scenario.id, + coreBondId: bond.id + }) + : 0; + return { + ...bond, + baseCoreChainRate, + camaraderieBonusRate, + coreChainRate: baseCoreChainRate + camaraderieBonusRate, + camaraderieRemembered: camaraderieBonusRate > 0, + selected: bond.id === selectedBondId + }; + }) .sort((left, right) => Number(right.selected) - Number(left.selected) || + Number(right.camaraderieRemembered) - Number(left.camaraderieRemembered) || right.coreChainRate - left.coreChainRate || right.level - left.level || left.title.localeCompare(right.title) || @@ -17304,7 +17395,9 @@ export class CampScene extends Phaser.Scene { const pairLabel = `${candidate.unitNames[0]}↔${candidate.unitNames[1]}`; this.sortiePlanFeedback = clearing ? `핵심 공명조 해제 · ${pairLabel}` - : `핵심 공명조 지정 · ${pairLabel} · 추격 ${candidate.coreChainRate}%`; + : candidate.camaraderieRemembered + ? `핵심 공명조 지정 · ${pairLabel} · 추격 ${candidate.baseCoreChainRate}% + 전우 기억 ${candidate.camaraderieBonusRate}%p = ${candidate.coreChainRate}%` + : `핵심 공명조 지정 · ${pairLabel} · 추격 ${candidate.coreChainRate}%`; if (guidedRecalculated) { this.sortiePlanFeedback += ' · 보완안 재계산'; } @@ -17326,7 +17419,8 @@ export class CampScene extends Phaser.Scene { const rule = this.nextSortieRule(scenario); const members = this.sortieRosterUnits().filter((unit) => this.sortieUnitAvailability(unit, scenario, rule).available); const availableIds = new Set(members.map((unit) => unit.id)); - return evaluateSortiePursuitPairs({ + const coreBondId = this.currentSortieResonanceBondId(scenario); + const pursuit = evaluateSortiePursuitPairs({ members: members.map((unit) => ({ id: unit.id, name: unit.name, @@ -17337,8 +17431,28 @@ export class CampScene extends Phaser.Scene { selectedUnitIds: selectedUnitIds.filter((unitId) => availableIds.has(unitId)), selectableUnitIds: (selectableUnitIds ?? [...availableIds]).filter((unitId) => availableIds.has(unitId)), battleId: scenario?.id, - coreBondId: this.currentSortieResonanceBondId(scenario) + coreBondId }); + const camaraderieBonusRate = firstBattleCamaraderieBonusFor({ + campaign: this.campaign, + battleId: scenario.id, + coreBondId + }); + if (camaraderieBonusRate <= 0 || !coreBondId) { + return pursuit; + } + return { + activePairs: pursuit.activePairs.map((pair) => + pair.core && pair.id === coreBondId + ? { ...pair, chainRate: pair.chainRate + camaraderieBonusRate } + : pair + ), + selectableOpportunities: pursuit.selectableOpportunities.map((opportunity) => + opportunity.core && opportunity.id === coreBondId + ? { ...opportunity, chainRate: opportunity.chainRate + camaraderieBonusRate } + : opportunity + ) + }; } private sortiePursuitPairKey(pair: Pick) { @@ -17347,14 +17461,23 @@ export class CampScene extends Phaser.Scene { private activeSortiePursuitPairs(snapshot: SortieSynergySnapshot) { const seenPairs = new Set(); - const coreBondId = this.currentSortieResonanceBondId(); + const scenario = this.nextSortieScenario(); + const coreBondId = this.currentSortieResonanceBondId(scenario); + const camaraderieBonusRate = scenario + ? firstBattleCamaraderieBonusFor({ + campaign: this.campaign, + battleId: scenario.id, + coreBondId + }) + : 0; return snapshot.activeBonds .map((bond) => { const core = bond.id === coreBondId && bond.level >= coreSortieResonanceMinLevel; + const baseCoreChainRate = coreSortieResonanceChainRateForLevel(bond.level); return { ...bond, baseChainRate: bond.chainRate, - chainRate: core ? coreSortieResonanceChainRateForLevel(bond.level) : bond.chainRate, + chainRate: core ? baseCoreChainRate + camaraderieBonusRate : bond.chainRate, core }; }) @@ -22532,6 +22655,7 @@ export class CampScene extends Phaser.Scene { const dialogues = this.availableCampDialogues(); const firstBattleFollowup = this.firstBattleCampFollowup(); + const camaraderieDialogue = this.firstBattleCamaraderieDialogue(); const compactDialogueList = dialogues.length > 3; const dialogueRowGap = compactDialogueList ? 35 : 64; const dialogueRowHeight = compactDialogueList ? 30 : 48; @@ -22550,23 +22674,26 @@ export class CampScene extends Phaser.Scene { this.campDialogueRowButtons[dialogue.id] = row; row.on('pointerdown', () => { soundDirector.playSelect(); + this.clearCampNotice(); this.selectedDialogueId = dialogue.id; this.render(); }); const isFirstBattleFollowup = firstBattleFollowup?.targetDialogueId === dialogue.id; - const title = isFirstBattleFollowup + const isCamaraderieMemory = camaraderieDialogue?.id === dialogue.id; + const title = isFirstBattleFollowup || isCamaraderieMemory ? this.compactText(dialogue.title, compactDialogueList ? 20 : 14) : dialogue.title; this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 3 : 8), title, this.textStyle(compactDialogueList ? 11 : 15, completed ? '#a8ffd0' : '#f2e3bf', true))); - if (isFirstBattleFollowup) { + if (isFirstBattleFollowup || isCamaraderieMemory) { + const badgeLabel = completed ? '완료' : isCamaraderieMemory ? '전우 기억' : '회고'; this.track( this.add.text( x + 320, rowY + (compactDialogueList ? 4 : 9), - completed ? '완료' : '회고', + badgeLabel, { ...this.textStyle(compactDialogueList ? 8 : 9, completed ? '#a8ffd0' : '#fff2b8', true), - backgroundColor: completed ? '#21402d' : '#8a3f25', + backgroundColor: completed ? '#21402d' : isCamaraderieMemory ? '#36507a' : '#8a3f25', padding: { x: 4, y: 2 } } ) @@ -23876,16 +24003,17 @@ export class CampScene extends Phaser.Scene { private showDialogueReward(dialogue: CampDialogue, choice: CampDialogueChoice, rewardExp: number) { const followup = this.firstBattleCampFollowup(); - const preparationLine = followup?.targetDialogueId === dialogue.id - ? `\n다음 준비 · ${followup.recommendationTitle}` - : ''; + const camaraderieMemory = this.firstBattleCamaraderieMemory(); + const preparationLine = camaraderieMemory?.dialogue.id === dialogue.id + ? `\n다음 준비 · ${camaraderieMemory.unitNames.join('↔')}를 직접 핵심 공명조로 지정하면 추격 +${camaraderieMemory.bonusRate}%p · 자동 지정 없음` + : followup?.targetDialogueId === dialogue.id + ? `\n다음 준비 · ${followup.recommendationTitle}` + : ''; this.showCampNotice(`획득 내역 · ${choice.label} · 공명 +${rewardExp}\n응답 · ${choice.response}${preparationLine}`); } private showCampNotice(message: string) { - this.tweens.killTweensOf(this.dialogueObjects); - this.dialogueObjects.forEach((object) => object.active && object.destroy()); - this.dialogueObjects = []; + this.clearCampNotice(); const depth = this.sortieObjects.length > 0 ? 72 : 30; const longestLineLength = Math.max(...message.split('\n').map((line) => line.length)); const noticeWidth = Math.min(760, Math.max(420, longestLineLength * 13)); @@ -23917,6 +24045,12 @@ export class CampScene extends Phaser.Scene { }); } + private clearCampNotice() { + this.tweens.killTweensOf(this.dialogueObjects); + this.dialogueObjects.forEach((object) => object.active && object.destroy()); + this.dialogueObjects = []; + } + private campNoticeHoldDuration(message: string) { const readableCharacterCount = message.replace(/\s/g, '').length; const paragraphCount = Math.max(1, message.split('\n').filter((line) => line.trim().length > 0).length); @@ -24869,10 +25003,18 @@ export class CampScene extends Phaser.Scene { ? this.completedCampDialogues().includes(cityStay.dialogue.id) : false; const firstBattleCampFollowup = this.firstBattleCampFollowup(); + const firstBattleCamaraderieMemory = this.firstBattleCamaraderieMemory(); + const firstBattleCamaraderieDialogue = this.firstBattleCamaraderieDialogue(); const availableCampDialogues = this.availableCampDialogues(); const selectedCampDialogue = availableCampDialogues.find( (dialogue) => dialogue.id === this.selectedDialogueId ) ?? availableCampDialogues[0]; + const firstBattleCamaraderieCandidate = firstBattleCamaraderieMemory + ? sortieCoreResonanceCandidates.find((candidate) => candidate.id === firstBattleCamaraderieMemory.bondId) + : undefined; + const firstBattleCamaraderieCandidateRow = firstBattleCamaraderieMemory + ? this.sortiePursuitPanelView?.rows.find((row) => row.bondId === firstBattleCamaraderieMemory.bondId) + : undefined; return { scene: this.scene.key, victoryRewardAcknowledgement: { @@ -24925,6 +25067,7 @@ export class CampScene extends Phaser.Scene { interactive: Boolean(button.bg.input?.enabled && button.text.input?.enabled), indicatorVisible: button.indicator.visible, newBadgeVisible: Boolean(button.newBadge?.visible), + newBadgeText: button.newBadge?.text ?? null, fillColor: button.bg.fillColor, strokeColor: button.bg.strokeColor, lineWidth: button.bg.lineWidth @@ -24962,6 +25105,65 @@ export class CampScene extends Phaser.Scene { ) } : null, + firstBattleCamaraderieMemory: firstBattleCamaraderieMemory + ? { + available: Boolean(firstBattleCamaraderieDialogue), + sourceBattleId: firstBattleCamaraderieMemory.sourceBattleId, + targetBattleId: firstBattleCamaraderieMemory.targetBattleId, + bondId: firstBattleCamaraderieMemory.bondId, + unitIds: [...firstBattleCamaraderieMemory.unitIds], + unitNames: [...firstBattleCamaraderieMemory.unitNames], + attempts: firstBattleCamaraderieMemory.attempts, + successes: firstBattleCamaraderieMemory.successes, + additionalDamage: firstBattleCamaraderieMemory.additionalDamage, + rememberedOutcome: firstBattleCamaraderieMemory.rememberedOutcome, + summary: firstBattleCamaraderieMemory.summary, + completed: firstBattleCamaraderieMemory.completed, + dialogue: { + id: firstBattleCamaraderieMemory.dialogue.id, + title: firstBattleCamaraderieMemory.dialogue.title, + lines: [...firstBattleCamaraderieMemory.dialogue.lines], + selected: this.selectedDialogueId === firstBattleCamaraderieMemory.dialogue.id, + completed: this.completedCampDialogues().includes(firstBattleCamaraderieMemory.dialogue.id), + choiceId: this.campaign?.campDialogueChoiceIds[firstBattleCamaraderieMemory.dialogue.id] ?? null, + rowBounds: this.sortieObjectBoundsDebug( + this.campDialogueRowButtons[firstBattleCamaraderieMemory.dialogue.id] + ), + bodyBounds: this.selectedDialogueId === firstBattleCamaraderieMemory.dialogue.id + ? this.sortieTextBoundsDebug(this.campDialogueBodyText) + : null, + choices: firstBattleCamaraderieMemory.dialogue.choices.map((choice) => ({ + id: choice.id, + label: choice.label, + response: choice.response, + rewardExp: firstBattleCamaraderieMemory.dialogue.rewardExp + choice.rewardExp, + bounds: this.sortieInteractiveObjectBoundsDebug( + this.campDialogueChoiceButtons[`${firstBattleCamaraderieMemory.dialogue.id}:${choice.id}`] + ) + })) + }, + perk: { + requiresManualSelection: true, + autoSelected: false, + exactPairOnly: true, + unlocked: firstBattleCamaraderieMemory.completed, + expectedBonusRate: firstBattleCamaraderieMemory.bonusRate, + candidateAvailable: Boolean(firstBattleCamaraderieCandidate), + selected: Boolean(firstBattleCamaraderieCandidate?.selected), + active: Boolean( + firstBattleCamaraderieCandidate?.selected && + firstBattleCamaraderieCandidate.camaraderieRemembered + ), + baseChainRate: firstBattleCamaraderieCandidate?.baseCoreChainRate ?? null, + bonusChainRate: firstBattleCamaraderieCandidate?.camaraderieBonusRate ?? 0, + effectiveChainRate: firstBattleCamaraderieCandidate?.coreChainRate ?? null, + cardText: firstBattleCamaraderieCandidateRow?.text ?? null, + cardBounds: this.sortieInteractiveObjectBoundsDebug( + firstBattleCamaraderieCandidateRow?.background + ) + } + } + : null, selectedDialogue: selectedCampDialogue ? { id: selectedCampDialogue.id, @@ -24970,6 +25172,7 @@ export class CampScene extends Phaser.Scene { lines: [...selectedCampDialogue.lines], completed: this.completedCampDialogues().includes(selectedCampDialogue.id), firstVictoryFollowup: firstBattleCampFollowup?.targetDialogueId === selectedCampDialogue.id, + camaraderieMemory: firstBattleCamaraderieMemory?.dialogue.id === selectedCampDialogue.id, rowBounds: this.sortieObjectBoundsDebug(this.campDialogueRowButtons[selectedCampDialogue.id]), bodyBounds: this.sortieTextBoundsDebug(this.campDialogueBodyText), choices: selectedCampDialogue.choices.map((choice) => ({ @@ -25814,6 +26017,10 @@ export class CampScene extends Phaser.Scene { title: candidate.title, level: candidate.level, baseChainRate: candidate.chainRate, + baseCoreChainRate: candidate.baseCoreChainRate, + bonusChainRate: candidate.camaraderieBonusRate, + effectiveCoreChainRate: candidate.coreChainRate, + camaraderieRemembered: candidate.camaraderieRemembered, coreChainRate: candidate.coreChainRate, selected: candidate.selected })), @@ -25860,6 +26067,10 @@ export class CampScene extends Phaser.Scene { rows: this.sortiePursuitPanelView?.rows.map((row) => ({ bondId: row.bondId, level: row.level, + baseChainRate: row.baseChainRate, + bonusChainRate: row.bonusChainRate, + effectiveChainRate: row.effectiveChainRate, + camaraderieRemembered: row.camaraderieRemembered, chainRate: row.chainRate, selected: row.selected, state: row.state, diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index 16be9a9..bffbd76 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -85,6 +85,12 @@ export type BattleSaveCoreResonanceStats = { additionalDamage: number; }; +export type BattleSaveCooperationStats = { + attempts: number; + successes: number; + additionalDamage: number; +}; + export type BattleSaveEventPriority = 'critical' | 'high' | 'normal' | 'low'; export type BattleSavePendingEvent = { @@ -104,6 +110,7 @@ export type BattleSaveState = { sortieResonanceBondId?: string; sortieRecommendation?: CampaignSortieRecommendationSnapshot; coreResonanceStats?: BattleSaveCoreResonanceStats; + cooperationStatsByBond?: Record; savedAt: string; turnNumber: number; activeFaction: BattleSaveFaction; @@ -199,6 +206,7 @@ export function normalizeBattleSaveState( } normalizeCoreResonanceSaveFields(cloned, options); + normalizeCooperationSaveFields(cloned, options); const recommendation = normalizeCampaignSortieRecommendationSnapshot(cloned.sortieRecommendation, { expectedBattleId: typeof cloned.battleId === 'string' ? cloned.battleId : options.expectedBattleId, allowedUnitIds: options.validAllyUnitIds @@ -244,6 +252,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida return false; } + if (!isOptionalCooperationStatsByBond(state.cooperationStatsByBond, state, options)) { + return false; + } + const attackIntents = state.attackIntents; const units = state.units; @@ -368,6 +380,131 @@ function normalizeCoreResonanceStatValue(value: unknown) { return Math.min(maxBattleUnitStatValue, Math.max(0, Math.floor(value))); } +function normalizeCooperationSaveFields( + state: Record, + options: BattleSaveValidationOptions +) { + const validBondIds = validDeployedCooperationBondIds(state, options); + const normalizedStats: Record = {}; + for (const [bondId, rawValue] of Object.entries( + isRecord(state.cooperationStatsByBond) ? state.cooperationStatsByBond : {} + ).sort(([leftBondId], [rightBondId]) => leftBondId.localeCompare(rightBondId))) { + if (Object.keys(normalizedStats).length >= maxBattleBondEntries) { + break; + } + const normalizedBondId = bondId.trim(); + if ( + normalizedBondId !== bondId || + !validBondIds.has(normalizedBondId) || + !isRecord(rawValue) + ) { + continue; + } + const attempts = normalizeCoreResonanceStatValue(rawValue.attempts); + if (attempts <= 0) { + continue; + } + const successes = Math.min( + attempts, + normalizeCoreResonanceStatValue(rawValue.successes) + ); + normalizedStats[normalizedBondId] = { + attempts, + successes, + additionalDamage: successes > 0 + ? normalizeCoreResonanceStatValue(rawValue.additionalDamage) + : 0 + }; + } + + if ( + Object.keys(normalizedStats).length === 0 && + typeof state.sortieResonanceBondId === 'string' && + validBondIds.has(state.sortieResonanceBondId) && + isRecord(state.coreResonanceStats) + ) { + const attempts = normalizeCoreResonanceStatValue(state.coreResonanceStats.attempts); + if (attempts > 0) { + const successes = Math.min( + attempts, + normalizeCoreResonanceStatValue(state.coreResonanceStats.successes) + ); + normalizedStats[state.sortieResonanceBondId] = { + attempts, + successes, + additionalDamage: successes > 0 + ? normalizeCoreResonanceStatValue(state.coreResonanceStats.additionalDamage) + : 0 + }; + } + } + + if (Object.keys(normalizedStats).length > 0) { + state.cooperationStatsByBond = normalizedStats; + } else { + delete state.cooperationStatsByBond; + } +} + +function validDeployedCooperationBondIds( + state: Record, + options: BattleSaveValidationOptions +) { + const deployedUnitIds = new Set( + (Array.isArray(state.units) ? state.units : []) + .filter(isRecord) + .map((unit) => unit.id) + .filter((unitId): unitId is string => typeof unitId === 'string') + ); + return new Set( + (Array.isArray(state.bonds) ? state.bonds : []) + .filter(isRecord) + .filter((bond) => ( + typeof bond.id === 'string' && + bond.id.trim() === bond.id && + bond.id.length > 0 && + Array.isArray(bond.unitIds) && + bond.unitIds.length === 2 && + bond.unitIds[0] !== bond.unitIds[1] && + bond.unitIds.every((unitId) => ( + typeof unitId === 'string' && + deployedUnitIds.has(unitId) && + (!options.validAllyUnitIds || options.validAllyUnitIds.has(unitId)) + )) + )) + .map((bond) => bond.id as string) + ); +} + +function isOptionalCooperationStatsByBond( + value: unknown, + state: Record, + options: BattleSaveValidationOptions +) { + if (value === undefined) { + return true; + } + if (!isRecord(value)) { + return false; + } + const entries = Object.entries(value); + if (entries.length > maxBattleBondEntries) { + return false; + } + const validBondIds = validDeployedCooperationBondIds(state, options); + return entries.every(([bondId, stats]) => ( + bondId.trim() === bondId && + validBondIds.has(bondId) && + isRecord(stats) && + isPositiveInteger(stats.attempts) && + isBattleUnitStatValue(stats.attempts) && + isBattleUnitStatValue(stats.successes) && + isBattleUnitStatValue(stats.additionalDamage) && + Number(stats.successes) <= Number(stats.attempts) && + (Number(stats.successes) > 0 || Number(stats.additionalDamage) === 0) + )); +} + function isOptionalCoreResonanceState( state: Record, options: BattleSaveValidationOptions diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 7097802..d064fc5 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -133,6 +133,15 @@ export type CampaignSortieRecommendationSnapshot = { unitReasons: Record; }; +export type CampaignSortieCooperationSnapshot = { + version: 1; + bondId: string; + unitIds: [string, string]; + attempts: number; + successes: number; + additionalDamage: number; +}; + export type CampaignSortieOrderResultSnapshot = SortieOrderResultSnapshot; export type FirstBattleReport = { @@ -153,6 +162,7 @@ export type FirstBattleReport = { sortieReview?: CampaignSortieReviewSnapshot; sortieOrder?: CampaignSortieOrderResultSnapshot; sortieRecommendation?: CampaignSortieRecommendationSnapshot; + sortieCooperation?: CampaignSortieCooperationSnapshot; completedCampDialogues: string[]; completedCampVisits: string[]; createdAt: string; @@ -436,6 +446,7 @@ export type CampaignBattleSettlement = { sortieReview?: CampaignSortieReviewSnapshot; sortieOrder?: CampaignSortieOrderResultSnapshot; sortieRecommendation?: CampaignSortieRecommendationSnapshot; + sortieCooperation?: CampaignSortieCooperationSnapshot; completedAt: string; }; @@ -1759,7 +1770,7 @@ function normalizeCampaignState(state: Partial): CampaignState { normalized.battleHistory = filterBattleHistoryRosterReferences( normalized.battleHistory, sortieUnitFilter, - new Set(normalized.bonds.map((bond) => bond.id)) + normalized.bonds ); } normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory); @@ -2131,6 +2142,72 @@ export function normalizeCampaignSortieRecommendationSnapshot( }; } +type CampaignSortieCooperationNormalizationOptions = { + allowedUnitIds?: ReadonlySet; + allowedBondIds?: ReadonlySet; + bonds?: readonly { + id: string; + unitIds: readonly string[]; + }[]; +}; + +export function normalizeCampaignSortieCooperationSnapshot( + value: unknown, + options: CampaignSortieCooperationNormalizationOptions = {} +): CampaignSortieCooperationSnapshot | undefined { + if (!isPlainObject(value) || value.version !== 1) { + return undefined; + } + const bondId = normalizeKeyString(value.bondId); + const rawUnitIds = arrayOrEmpty(value.unitIds); + if (!bondId || rawUnitIds.length !== 2) { + return undefined; + } + const unitIds = rawUnitIds.map(normalizeKeyString); + if ( + !unitIds[0] || + !unitIds[1] || + unitIds[0] === unitIds[1] || + (options.allowedBondIds && !options.allowedBondIds.has(bondId)) || + unitIds.some((unitId) => options.allowedUnitIds && !options.allowedUnitIds.has(unitId)) + ) { + return undefined; + } + + const matchingBond = options.bonds?.find((bond) => bond.id === bondId); + if (options.bonds && !matchingBond) { + return undefined; + } + if ( + matchingBond && + ( + matchingBond.unitIds.length !== 2 || + !matchingBond.unitIds.every((unitId) => unitIds.includes(unitId)) + ) + ) { + return undefined; + } + + const attempts = normalizeNonNegativeInteger(value.attempts); + if (attempts <= 0) { + return undefined; + } + const normalizedUnitIds = matchingBond + ? [matchingBond.unitIds[0], matchingBond.unitIds[1]] as [string, string] + : [unitIds[0], unitIds[1]] as [string, string]; + const successes = Math.min(attempts, normalizeNonNegativeInteger(value.successes)); + return { + version: 1, + bondId, + unitIds: normalizedUnitIds, + attempts, + successes, + additionalDamage: successes > 0 + ? normalizeNonNegativeInteger(value.additionalDamage) + : 0 + }; +} + function resolveCampaignSortieResonanceBond( battleId: string, bondId: string, @@ -2398,14 +2475,21 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost allowedUnitIds: rosterUnitIds }) : undefined; + const filteredBonds = report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId))); + const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(report.sortieCooperation, { + allowedUnitIds: rosterUnitIds, + allowedBondIds: new Set(filteredBonds.map((bond) => bond.id)), + bonds: filteredBonds + }); const filtered = { ...report, units: report.units.filter((unit) => unit.faction !== 'ally' || rosterUnitIds.has(unit.id)), - bonds: report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId))), + bonds: filteredBonds, ...(sortiePerformance ? { sortiePerformance } : {}), ...(sortieReview ? { sortieReview } : {}), ...(sortieOrder ? { sortieOrder } : {}), - ...(sortieRecommendation ? { sortieRecommendation } : {}) + ...(sortieRecommendation ? { sortieRecommendation } : {}), + ...(sortieCooperation ? { sortieCooperation } : {}) }; if (!sortiePerformance) { delete filtered.sortiePerformance; @@ -2419,6 +2503,9 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost if (!sortieRecommendation) { delete filtered.sortieRecommendation; } + if (!sortieCooperation) { + delete filtered.sortieCooperation; + } if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) { delete filtered.mvp; } @@ -2428,8 +2515,9 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost function filterBattleHistoryRosterReferences( history: Record, rosterUnitIds: Set, - bondIds: Set + campaignBonds: readonly CampBondSnapshot[] ): Record { + const bondIds = new Set(campaignBonds.map((bond) => bond.id)); return Object.entries(history).reduce>((filteredHistory, [battleId, settlement]) => { const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance, rosterUnitIds); const sortiePerformanceUnchanged = sortiePerformanceSnapshotsShareRoster(settlement.sortiePerformance, sortiePerformance); @@ -2448,6 +2536,11 @@ function filterBattleHistoryRosterReferences( allowedUnitIds: rosterUnitIds }) : undefined; + const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(settlement.sortieCooperation, { + allowedUnitIds: rosterUnitIds, + allowedBondIds: bondIds, + bonds: campaignBonds + }); filteredHistory[battleId] = { ...settlement, units: settlement.units.filter((unit) => rosterUnitIds.has(unit.unitId)), @@ -2456,7 +2549,8 @@ function filterBattleHistoryRosterReferences( ...(sortiePerformance ? { sortiePerformance } : {}), ...(sortieReview ? { sortieReview } : {}), ...(sortieOrder ? { sortieOrder } : {}), - ...(sortieRecommendation ? { sortieRecommendation } : {}) + ...(sortieRecommendation ? { sortieRecommendation } : {}), + ...(sortieCooperation ? { sortieCooperation } : {}) }; if (!sortiePerformance) { delete filteredHistory[battleId].sortiePerformance; @@ -2470,6 +2564,9 @@ function filterBattleHistoryRosterReferences( if (!sortieRecommendation) { delete filteredHistory[battleId].sortieRecommendation; } + if (!sortieCooperation) { + delete filteredHistory[battleId].sortieCooperation; + } return filteredHistory; }, {}); } @@ -2505,6 +2602,20 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle turnNumber }) : undefined; + const units = normalizeLimitedArray( + settlement.units, + normalizeCampaignUnitProgressSnapshot, + maxCampaignBattleUnitEntries + ); + const bonds = normalizeLimitedArray( + settlement.bonds, + normalizeCampaignBondProgressSnapshot, + maxCampaignBattleBondEntries + ); + const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(settlement.sortieCooperation, { + allowedUnitIds: new Set(units.map((unit) => unit.unitId)), + allowedBondIds: new Set(bonds.map((bond) => bond.id)) + }); return { battleId, @@ -2515,13 +2626,14 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle itemRewards: normalizeRewardStrings(settlement.itemRewards), campaignRewards: cloneCampaignRewardSnapshot(settlement.campaignRewards, battleId), objectives: normalizeLimitedArray(settlement.objectives, normalizeBattleObjectiveSnapshot, maxCampaignBattleObjectiveEntries), - units: normalizeLimitedArray(settlement.units, normalizeCampaignUnitProgressSnapshot, maxCampaignBattleUnitEntries), - bonds: normalizeLimitedArray(settlement.bonds, normalizeCampaignBondProgressSnapshot, maxCampaignBattleBondEntries), + units, + bonds, reserveTraining: normalizeLimitedArray(settlement.reserveTraining, normalizeReserveTrainingSnapshot, maxCampaignReserveTrainingEntries), ...(sortiePerformance ? { sortiePerformance } : {}), ...(sortieReview ? { sortieReview } : {}), ...(sortieOrder ? { sortieOrder } : {}), ...(sortieRecommendation ? { sortieRecommendation } : {}), + ...(sortieCooperation ? { sortieCooperation } : {}), completedAt }; } @@ -2856,6 +2968,23 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi turnNumber }) : undefined; + const units = normalizeLimitedArray( + report.units, + normalizeUnitDataSnapshot, + maxCampaignBattleUnitEntries + ); + const bonds = normalizeLimitedArray( + report.bonds, + normalizeCampBondSnapshot, + maxCampaignBattleBondEntries + ); + const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(report.sortieCooperation, { + allowedUnitIds: new Set( + units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id) + ), + allowedBondIds: new Set(bonds.map((bond) => bond.id)), + bonds + }); return { battleId, @@ -2866,8 +2995,8 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi defeatedEnemies: normalizeNonNegativeInteger(report.defeatedEnemies), totalEnemies: normalizeNonNegativeInteger(report.totalEnemies), objectives: normalizeLimitedArray(report.objectives, normalizeBattleObjectiveSnapshot, maxCampaignBattleObjectiveEntries), - units: normalizeLimitedArray(report.units, normalizeUnitDataSnapshot, maxCampaignBattleUnitEntries), - bonds: normalizeLimitedArray(report.bonds, normalizeCampBondSnapshot, maxCampaignBattleBondEntries), + units, + bonds, mvp: normalizeCampMvpSnapshot(report.mvp), itemRewards: normalizeRewardStrings(report.itemRewards), campaignRewards: cloneCampaignRewardSnapshot( @@ -2878,6 +3007,7 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi ...(sortieReview ? { sortieReview } : {}), ...(sortieOrder ? { sortieOrder } : {}), ...(sortieRecommendation ? { sortieRecommendation } : {}), + ...(sortieCooperation ? { sortieCooperation } : {}), completedCampDialogues: uniqueCampHistoryIds(report.completedCampDialogues), completedCampVisits: uniqueCampHistoryIds(report.completedCampVisits), createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString() @@ -3142,6 +3272,9 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp ...(report.sortieRecommendation ? { sortieRecommendation: cloneCampaignSortieRecommendationSnapshot(report.sortieRecommendation) } : {}), + ...(report.sortieCooperation + ? { sortieCooperation: cloneCampaignSortieCooperationSnapshot(report.sortieCooperation) } + : {}), completedAt: report.createdAt }; } @@ -3302,6 +3435,9 @@ function cloneReport(report: FirstBattleReport): FirstBattleReport { if (cloned.campaignRewards) { cloned.campaignRewards = cloneCampaignRewardSnapshot(cloned.campaignRewards, cloned.battleId); } + if (cloned.sortieCooperation) { + cloned.sortieCooperation = cloneCampaignSortieCooperationSnapshot(cloned.sortieCooperation); + } return cloned; } @@ -3314,6 +3450,13 @@ export function cloneCampaignSortieRecommendationSnapshot(snapshot: CampaignSort } satisfies CampaignSortieRecommendationSnapshot; } +export function cloneCampaignSortieCooperationSnapshot(snapshot: CampaignSortieCooperationSnapshot) { + return { + ...snapshot, + unitIds: [...snapshot.unitIds] as [string, string] + } satisfies CampaignSortieCooperationSnapshot; +} + function cloneCampaignRewardSnapshot(rewards?: CampaignRewardSnapshot, battleId?: string): CampaignRewardSnapshot | undefined { if (!rewards) { return undefined;