feat: carry first-battle camaraderie into sortie prep
This commit is contained in:
@@ -128,9 +128,26 @@ RPG로 다듬는다.
|
||||
- 미달했다면 같은 군령의 첫 미달 조건을 보완하고, 완수했다면 잔당
|
||||
추격전의 성격에 맞는 기동 군령을 다음 학습 과제로 제안한다.
|
||||
|
||||
다음 초반 묶음은 전투에서 실제로 함께 협공·엄호·회복한 사건을 동료가
|
||||
짧게 기억하고, 거점의 공명 대화와 다음 전투의 작은 협동 효과로 연결하는
|
||||
것을 우선 검토한다.
|
||||
### 완료한 5차 묶음: 실제 협공을 전우의 기억으로
|
||||
|
||||
- 첫 전투의 일반·핵심 공명 추격을 관계별로 기록하고, 시도·성공·추가
|
||||
피해를 전투 저장과 결과 보고서에 함께 보존한다.
|
||||
- 성공한 관계를 우선하되 성공 횟수, 추가 피해, 시도 횟수와 안정적인
|
||||
관계 ID 순으로 대표 기억 하나를 결정한다. 실패한 시도만 있어도
|
||||
엇갈린 호흡을 보완하는 대화로 회수한다.
|
||||
- 첫 군영에 실제 두 사람과 결과를 반영한 `전우 기억` 대화를 추가하고,
|
||||
선택을 마친 뒤에도 출전 조합은 자동으로 바꾸지 않는다.
|
||||
- 플레이어가 다음 황건 추격전에서 기억 속 두 사람을 직접 핵심 공명조로
|
||||
지정한 경우에만 추격 확률을 `+5%p` 높인다.
|
||||
- 후보 카드와 선택·전투 결과에는 기본 확률, 전우 기억 보너스와 최종
|
||||
확률을 나누어 보여 주며, 다른 조합·무선택·이후 전투에는 보너스를
|
||||
적용하지 않는다.
|
||||
- 오래된 저장은 그대로 읽고, 오염된 관계·무장 참조는 제거하며, 전투
|
||||
저장 재개와 두 번째 전투 재도전에서도 보너스를 중복 가산하지 않는다.
|
||||
|
||||
다음 초반 묶음은 정보 수집·장비 구매·동료 대화 가운데 무엇을 먼저
|
||||
선택했는지가 출전 브리핑과 첫 턴의 전술 선택에 짧고 명확하게 되돌아오는
|
||||
경로를 감사한다.
|
||||
|
||||
## 장기 개선 순서
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
450
scripts/verify-first-battle-camaraderie-memory.mjs
Normal file
450
scripts/verify-first-battle-camaraderie-memory.mjs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
341
src/game/data/firstBattleCamaraderieMemory.ts
Normal file
341
src/game/data/firstBattleCamaraderieMemory.ts
Normal file
@@ -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<Record<string, CampaignBattleSettlement | undefined>>;
|
||||
completedCampDialogues?: readonly string[];
|
||||
roster?: readonly {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
type CooperationBond = {
|
||||
id: string;
|
||||
unitIds: readonly string[];
|
||||
};
|
||||
|
||||
export function selectDominantSortieCooperationSnapshot(options: {
|
||||
statsByBond?: Readonly<
|
||||
Record<string, SortieCooperationStats | undefined>
|
||||
>;
|
||||
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<CampaignSortieCooperationSnapshot>(([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<string, string>();
|
||||
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;
|
||||
}
|
||||
@@ -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<string, SortieCooperationStats>();
|
||||
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<BondState, 'id' | 'level'>) {
|
||||
private baseBondChainRateForBond(bond: Pick<BondState, 'id' | 'level'>) {
|
||||
return this.isCoreSortieResonanceBond(bond)
|
||||
? coreSortieResonanceChainRateForLevel(bond.level)
|
||||
: sortieBondBonusForLevel(bond.level).chainRate;
|
||||
}
|
||||
|
||||
private camaraderieMemoryBonusForBond(bond: Pick<BondState, 'id' | 'level'>) {
|
||||
if (
|
||||
!this.isCoreSortieResonanceBond(bond) ||
|
||||
bond.id !== this.launchCamaraderieMemoryBondId
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return this.launchCamaraderieMemoryBonus;
|
||||
}
|
||||
|
||||
private bondChainRateBreakdown(bond: Pick<BondState, 'id' | 'level'>) {
|
||||
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<BondState, 'id' | 'level'>) {
|
||||
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,
|
||||
|
||||
@@ -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<string>();
|
||||
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<SortieSynergyActiveBond, 'unitIds'>) {
|
||||
@@ -17347,14 +17461,23 @@ export class CampScene extends Phaser.Scene {
|
||||
|
||||
private activeSortiePursuitPairs(snapshot: SortieSynergySnapshot) {
|
||||
const seenPairs = new Set<string>();
|
||||
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,
|
||||
|
||||
@@ -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<string, BattleSaveCooperationStats>;
|
||||
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<string, unknown>,
|
||||
options: BattleSaveValidationOptions
|
||||
) {
|
||||
const validBondIds = validDeployedCooperationBondIds(state, options);
|
||||
const normalizedStats: Record<string, BattleSaveCooperationStats> = {};
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
options: BattleSaveValidationOptions
|
||||
|
||||
@@ -133,6 +133,15 @@ export type CampaignSortieRecommendationSnapshot = {
|
||||
unitReasons: Record<string, string>;
|
||||
};
|
||||
|
||||
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>): 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<string>;
|
||||
allowedBondIds?: ReadonlySet<string>;
|
||||
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<unknown>(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<string, CampaignBattleSettlement>,
|
||||
rosterUnitIds: Set<string>,
|
||||
bondIds: Set<string>
|
||||
campaignBonds: readonly CampBondSnapshot[]
|
||||
): Record<string, CampaignBattleSettlement> {
|
||||
const bondIds = new Set(campaignBonds.map((bond) => bond.id));
|
||||
return Object.entries(history).reduce<Record<string, CampaignBattleSettlement>>((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;
|
||||
|
||||
Reference in New Issue
Block a user