feat: carry first-battle camaraderie into sortie prep

This commit is contained in:
2026-07-27 06:57:33 +09:00
parent b963ef34cb
commit 17486efa8b
10 changed files with 2207 additions and 89 deletions

View File

@@ -128,9 +128,26 @@ RPG로 다듬는다.
- 미달했다면 같은 군령의 첫 미달 조건을 보완하고, 완수했다면 잔당
추격전의 성격에 맞는 기동 군령을 다음 학습 과제로 제안한다.
다음 초반 묶음은 전투에서 실제로 함께 협공·엄호·회복한 사건을 동료가
짧게 기억하고, 거점의 공명 대화와 다음 전투의 작은 협동 효과로 연결하는
것을 우선 검토한다.
### 완료한 5차 묶음: 실제 협공을 전우의 기억으로
- 첫 전투의 일반·핵심 공명 추격을 관계별로 기록하고, 시도·성공·추가
피해를 전투 저장과 결과 보고서에 함께 보존한다.
- 성공한 관계를 우선하되 성공 횟수, 추가 피해, 시도 횟수와 안정적인
관계 ID 순으로 대표 기억 하나를 결정한다. 실패한 시도만 있어도
엇갈린 호흡을 보완하는 대화로 회수한다.
- 첫 군영에 실제 두 사람과 결과를 반영한 `전우 기억` 대화를 추가하고,
선택을 마친 뒤에도 출전 조합은 자동으로 바꾸지 않는다.
- 플레이어가 다음 황건 추격전에서 기억 속 두 사람을 직접 핵심 공명조로
지정한 경우에만 추격 확률을 `+5%p` 높인다.
- 후보 카드와 선택·전투 결과에는 기본 확률, 전우 기억 보너스와 최종
확률을 나누어 보여 주며, 다른 조합·무선택·이후 전투에는 보너스를
적용하지 않는다.
- 오래된 저장은 그대로 읽고, 오염된 관계·무장 참조는 제거하며, 전투
저장 재개와 두 번째 전투 재도전에서도 보너스를 중복 가산하지 않는다.
다음 초반 묶음은 정보 수집·장비 구매·동료 대화 가운데 무엇을 먼저
선택했는지가 출전 브리핑과 첫 턴의 전술 선택에 짧고 명확하게 되돌아오는
경로를 감사한다.
## 장기 개선 순서

View File

@@ -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",

View 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);
}
}

View File

@@ -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');

View File

@@ -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',

View 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;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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;