feat: add post-battle formation review
This commit is contained in:
@@ -35,6 +35,7 @@ try {
|
||||
normalizeCampaignReserveTrainingAssignments,
|
||||
normalizeCampaignSortieFormationPresets,
|
||||
normalizeCampaignSortieItemAssignments,
|
||||
normalizeCampaignSortiePerformanceSnapshot,
|
||||
resetCampaignState,
|
||||
saveCampaignState,
|
||||
setCampaignState,
|
||||
@@ -45,6 +46,24 @@ try {
|
||||
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
||||
const { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts');
|
||||
const { normalizeSortieFormationAssignments } = await server.ssrLoadModule('/src/game/data/sortieDeployment.ts');
|
||||
const sortieStatsFixture = (unitId, overrides = {}) => ({
|
||||
unitId,
|
||||
hpBefore: 30,
|
||||
maxHpBefore: 30,
|
||||
damageDealt: 10,
|
||||
damageTaken: 5,
|
||||
defeats: 1,
|
||||
actions: 2,
|
||||
support: 0,
|
||||
sortieBonusDamage: 1,
|
||||
sortiePreventedDamage: 2,
|
||||
sortieBonusHealing: 0,
|
||||
sortieExtendedBondTriggers: 0,
|
||||
intentDefeats: 0,
|
||||
intentLineBreaks: 0,
|
||||
intentGuardSuccesses: 0,
|
||||
...overrides
|
||||
});
|
||||
|
||||
storage.clear();
|
||||
const empty = loadCampaignState();
|
||||
@@ -155,6 +174,88 @@ try {
|
||||
!Object.prototype.hasOwnProperty.call(cappedSortiePresets.elite, 'sortieItemAssignments'),
|
||||
`Expected each sortie preset and its role map to stay capped at 96 entries without persisting supplies: ${JSON.stringify(cappedSortiePresets)}`
|
||||
);
|
||||
const normalizedSortiePerformance = normalizeCampaignSortiePerformanceSnapshot(
|
||||
{
|
||||
selectedUnitIds: [' liu-bei ', 'guan-yu', 'liu-bei', 'ghost-unit', '', 7, 'x'.repeat(97)],
|
||||
formationAssignments: {
|
||||
' liu-bei ': 'front',
|
||||
'liu-bei': 'support',
|
||||
'guan-yu': 'flank',
|
||||
'ghost-unit': 'reserve',
|
||||
broken: 'center'
|
||||
},
|
||||
unitStats: [
|
||||
sortieStatsFixture(' liu-bei ', {
|
||||
hpBefore: '28',
|
||||
maxHpBefore: '20',
|
||||
damageDealt: '1000000',
|
||||
damageTaken: -4,
|
||||
defeats: '2.9',
|
||||
actions: 'not-a-number',
|
||||
support: '7',
|
||||
sortieBonusDamage: '3',
|
||||
sortiePreventedDamage: '4',
|
||||
sortieBonusHealing: '5',
|
||||
sortieExtendedBondTriggers: '6',
|
||||
intentDefeats: '7',
|
||||
intentLineBreaks: '8',
|
||||
intentGuardSuccesses: '9',
|
||||
finalHp: 1,
|
||||
sortieItemAssignments: { bean: 2 }
|
||||
}),
|
||||
sortieStatsFixture('liu-bei', { damageDealt: 1 }),
|
||||
sortieStatsFixture('guan-yu', { hpBefore: '12', maxHpBefore: '30', support: '4' }),
|
||||
sortieStatsFixture('ghost-unit'),
|
||||
{ broken: true }
|
||||
],
|
||||
sortieItemAssignments: { 'liu-bei': { bean: 2 } },
|
||||
itemAssignments: { 'guan-yu': { wine: 1 } }
|
||||
},
|
||||
new Set(['liu-bei', 'guan-yu'])
|
||||
);
|
||||
const normalizedLiuBeiPerformance = normalizedSortiePerformance?.unitStats.find((stats) => stats.unitId === 'liu-bei');
|
||||
const normalizedGuanYuPerformance = normalizedSortiePerformance?.unitStats.find((stats) => stats.unitId === 'guan-yu');
|
||||
assert(
|
||||
JSON.stringify(normalizedSortiePerformance?.selectedUnitIds) === JSON.stringify(['liu-bei', 'guan-yu']) &&
|
||||
JSON.stringify(normalizedSortiePerformance?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front', 'guan-yu': 'flank' }) &&
|
||||
normalizedSortiePerformance?.unitStats.length === 2 &&
|
||||
normalizedLiuBeiPerformance?.hpBefore === 28 &&
|
||||
normalizedLiuBeiPerformance.maxHpBefore === 28 &&
|
||||
normalizedLiuBeiPerformance.damageDealt === 999999 &&
|
||||
normalizedLiuBeiPerformance.damageTaken === 0 &&
|
||||
normalizedLiuBeiPerformance.defeats === 2 &&
|
||||
normalizedLiuBeiPerformance.actions === 0 &&
|
||||
normalizedLiuBeiPerformance.support === 7 &&
|
||||
normalizedLiuBeiPerformance.sortieBonusDamage === 3 &&
|
||||
normalizedLiuBeiPerformance.sortiePreventedDamage === 4 &&
|
||||
normalizedLiuBeiPerformance.sortieBonusHealing === 5 &&
|
||||
normalizedLiuBeiPerformance.sortieExtendedBondTriggers === 6 &&
|
||||
normalizedLiuBeiPerformance.intentDefeats === 7 &&
|
||||
normalizedLiuBeiPerformance.intentLineBreaks === 8 &&
|
||||
normalizedLiuBeiPerformance.intentGuardSuccesses === 9 &&
|
||||
normalizedGuanYuPerformance?.maxHpBefore === 30 &&
|
||||
normalizedGuanYuPerformance.support === 4 &&
|
||||
!Object.prototype.hasOwnProperty.call(normalizedSortiePerformance ?? {}, 'sortieItemAssignments') &&
|
||||
!Object.prototype.hasOwnProperty.call(normalizedSortiePerformance ?? {}, 'itemAssignments') &&
|
||||
!Object.prototype.hasOwnProperty.call(normalizedLiuBeiPerformance ?? {}, 'finalHp') &&
|
||||
!Object.prototype.hasOwnProperty.call(normalizedLiuBeiPerformance ?? {}, 'sortieItemAssignments'),
|
||||
`Expected sortie performance snapshots to normalize ids, roles, HP baselines, numeric contribution fields, duplicates, and exclude supplies: ${JSON.stringify(normalizedSortiePerformance)}`
|
||||
);
|
||||
const cappedSortiePerformance = normalizeCampaignSortiePerformanceSnapshot({
|
||||
selectedUnitIds: Array.from({ length: 120 }, (_, index) => `unit-${index}`),
|
||||
formationAssignments: Object.fromEntries(Array.from({ length: 120 }, (_, index) => [`unit-${index}`, 'front'])),
|
||||
unitStats: Array.from({ length: 120 }, (_, index) => sortieStatsFixture(`unit-${index}`))
|
||||
});
|
||||
assert(
|
||||
cappedSortiePerformance?.selectedUnitIds.length === 96 &&
|
||||
cappedSortiePerformance.selectedUnitIds[95] === 'unit-95' &&
|
||||
cappedSortiePerformance.formationAssignments['unit-95'] === 'front' &&
|
||||
cappedSortiePerformance.formationAssignments['unit-96'] === undefined &&
|
||||
cappedSortiePerformance.unitStats.length === 96 &&
|
||||
cappedSortiePerformance.unitStats[95].unitId === 'unit-95' &&
|
||||
normalizeCampaignSortiePerformanceSnapshot({ selectedUnitIds: [], unitStats: [] }) === undefined,
|
||||
`Expected sortie performance snapshots to stay capped and empty snapshots to be discarded: ${JSON.stringify(cappedSortiePerformance)}`
|
||||
);
|
||||
|
||||
storage.clear();
|
||||
storage.set(
|
||||
@@ -303,6 +404,47 @@ try {
|
||||
completedCampVisits: [],
|
||||
createdAt: '2026-07-03T09:00:00.000Z'
|
||||
};
|
||||
const performanceUnit = alliedFirstBattleUnits.find((unit) => unit.id === 'liu-bei') ?? alliedFirstBattleUnits[0];
|
||||
assert(performanceUnit, 'Expected an allied first-battle unit for sortie performance clone checks');
|
||||
const performanceReport = {
|
||||
...firstBattleReport,
|
||||
sortiePerformance: {
|
||||
selectedUnitIds: [performanceUnit.id],
|
||||
formationAssignments: { [performanceUnit.id]: 'front' },
|
||||
unitStats: [sortieStatsFixture(performanceUnit.id, { hpBefore: performanceUnit.maxHp, maxHpBefore: performanceUnit.maxHp, damageDealt: 24 })]
|
||||
},
|
||||
createdAt: '2026-07-03T09:05:00.000Z'
|
||||
};
|
||||
resetCampaignState();
|
||||
setFirstBattleReport(performanceReport);
|
||||
performanceReport.sortiePerformance.selectedUnitIds.push('ghost-unit');
|
||||
performanceReport.sortiePerformance.formationAssignments[performanceUnit.id] = 'support';
|
||||
performanceReport.sortiePerformance.unitStats[0].damageDealt = 999;
|
||||
const clonedPerformanceSettlement = getCampaignState();
|
||||
const clonedReportPerformance = clonedPerformanceSettlement.firstBattleReport?.sortiePerformance;
|
||||
const clonedHistoryPerformance = clonedPerformanceSettlement.battleHistory[firstScenario.id]?.sortiePerformance;
|
||||
assert(
|
||||
JSON.stringify(clonedReportPerformance?.selectedUnitIds) === JSON.stringify([performanceUnit.id]) &&
|
||||
clonedReportPerformance.formationAssignments[performanceUnit.id] === 'front' &&
|
||||
clonedReportPerformance.unitStats[0].damageDealt === 24 &&
|
||||
JSON.stringify(clonedHistoryPerformance?.selectedUnitIds) === JSON.stringify([performanceUnit.id]) &&
|
||||
clonedHistoryPerformance.formationAssignments[performanceUnit.id] === 'front' &&
|
||||
clonedHistoryPerformance.unitStats[0].damageDealt === 24,
|
||||
`Expected battle report settlement to deep-clone nested sortie performance input: ${JSON.stringify(clonedPerformanceSettlement)}`
|
||||
);
|
||||
clonedReportPerformance.selectedUnitIds.push('detached-unit');
|
||||
clonedReportPerformance.formationAssignments[performanceUnit.id] = 'reserve';
|
||||
clonedReportPerformance.unitStats[0].damageDealt = 777;
|
||||
const unmutatedPerformanceSettlement = getCampaignState();
|
||||
assert(
|
||||
clonedHistoryPerformance.unitStats[0].damageDealt === 24 &&
|
||||
JSON.stringify(unmutatedPerformanceSettlement.firstBattleReport?.sortiePerformance?.selectedUnitIds) === JSON.stringify([performanceUnit.id]) &&
|
||||
unmutatedPerformanceSettlement.firstBattleReport?.sortiePerformance?.formationAssignments[performanceUnit.id] === 'front' &&
|
||||
unmutatedPerformanceSettlement.firstBattleReport?.sortiePerformance?.unitStats[0].damageDealt === 24 &&
|
||||
unmutatedPerformanceSettlement.battleHistory[firstScenario.id]?.sortiePerformance?.unitStats[0].damageDealt === 24,
|
||||
`Expected returned report, history, and persisted sortie performance snapshots to remain deeply detached: ${JSON.stringify(unmutatedPerformanceSettlement)}`
|
||||
);
|
||||
resetCampaignState();
|
||||
const reserveDrillTemplate = campaignRecruitUnits.find((unit) => unit.id === 'jian-yong');
|
||||
assert(reserveDrillTemplate, 'Expected Jian Yong recruit template to exist for reserve drill assignment checks');
|
||||
storage.clear();
|
||||
@@ -357,6 +499,11 @@ try {
|
||||
repeatedSettlement.battleHistory[firstScenario.id]?.rewardGold === 100,
|
||||
`Expected repeated battle report settlement to avoid duplicate gold/items: ${JSON.stringify(repeatedSettlement)}`
|
||||
);
|
||||
assert(
|
||||
repeatedSettlement.firstBattleReport?.sortiePerformance === undefined &&
|
||||
repeatedSettlement.battleHistory[firstScenario.id]?.sortiePerformance === undefined,
|
||||
`Expected legacy reports without sortie performance to remain compatible in both latest report and history: ${JSON.stringify(repeatedSettlement)}`
|
||||
);
|
||||
assert(
|
||||
repeatedSettlement.firstBattleReport?.campaignRewards?.unlocks[0]?.battleId === 'second-battle-yellow-turban-pursuit' &&
|
||||
repeatedSettlement.firstBattleReport.campaignRewards.unlocks[0].title === firstScenarioUnlocks[0]?.title &&
|
||||
@@ -772,7 +919,12 @@ try {
|
||||
{ id: 'liu-bei__guan-yu', title: 'Oath Bond', unitIds: ['liu-bei', 'guan-yu'], level: 2, exp: 30, battleExp: 4 },
|
||||
{ id: 'liu-bei__ghost', title: 'Ghost Bond', unitIds: ['liu-bei', 'ghost-unit'], level: 3, exp: 40, battleExp: 5 }
|
||||
],
|
||||
mvp: { unitId: 'ghost-unit', name: 'Ghost', damageDealt: 99, defeats: 4 }
|
||||
mvp: { unitId: 'ghost-unit', name: 'Ghost', damageDealt: 99, defeats: 4 },
|
||||
sortiePerformance: {
|
||||
selectedUnitIds: ['liu-bei', 'ghost-unit', 'liu-bei'],
|
||||
formationAssignments: { 'liu-bei': 'front', 'ghost-unit': 'support' },
|
||||
unitStats: [sortieStatsFixture('liu-bei', { damageDealt: 31 }), sortieStatsFixture('ghost-unit', { damageDealt: 99 })]
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -780,8 +932,13 @@ try {
|
||||
assert(
|
||||
staleReportRefs.firstBattleReport?.bonds.length === 1 &&
|
||||
staleReportRefs.firstBattleReport.bonds[0].id === 'liu-bei__guan-yu' &&
|
||||
staleReportRefs.firstBattleReport.mvp === undefined,
|
||||
`Expected first battle report bonds and MVP to be filtered against the recovered roster: ${JSON.stringify(staleReportRefs)}`
|
||||
staleReportRefs.firstBattleReport.mvp === undefined &&
|
||||
JSON.stringify(staleReportRefs.firstBattleReport.sortiePerformance?.selectedUnitIds) === JSON.stringify(['liu-bei']) &&
|
||||
JSON.stringify(staleReportRefs.firstBattleReport.sortiePerformance?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front' }) &&
|
||||
staleReportRefs.firstBattleReport.sortiePerformance?.unitStats.length === 1 &&
|
||||
staleReportRefs.firstBattleReport.sortiePerformance.unitStats[0].unitId === 'liu-bei' &&
|
||||
staleReportRefs.firstBattleReport.sortiePerformance.unitStats[0].damageDealt === 31,
|
||||
`Expected first battle report bonds, MVP, and sortie performance references to be filtered against the recovered roster: ${JSON.stringify(staleReportRefs)}`
|
||||
);
|
||||
|
||||
storage.clear();
|
||||
@@ -956,6 +1113,25 @@ try {
|
||||
}))
|
||||
],
|
||||
mvp: { unitId: 'liu-bei', name: 'Liu Bei', damageDealt: '1000000', defeats: '2' },
|
||||
sortiePerformance: {
|
||||
selectedUnitIds: [' liu-bei ', 'ghost-unit', 'liu-bei', '', 9],
|
||||
formationAssignments: { ' liu-bei ': 'front', 'ghost-unit': 'support', broken: 'center' },
|
||||
unitStats: [
|
||||
sortieStatsFixture(' liu-bei ', {
|
||||
hpBefore: '40',
|
||||
maxHpBefore: '30',
|
||||
damageDealt: '1000000',
|
||||
damageTaken: '-2',
|
||||
defeats: '3.8',
|
||||
actions: '4',
|
||||
support: '5'
|
||||
}),
|
||||
sortieStatsFixture('liu-bei', { damageDealt: 1 }),
|
||||
sortieStatsFixture('ghost-unit', { damageDealt: 88 }),
|
||||
'broken-performance'
|
||||
],
|
||||
sortieItemAssignments: { 'liu-bei': { bean: 2 } }
|
||||
},
|
||||
itemRewards: ['Bean', 'Bean', 'Bean -2', 'Bean +0', 10, 'x'.repeat(97)],
|
||||
campaignRewards: {
|
||||
supplies: [' Bean ', 'Bean', 'Bean -2', 'Bean +0', 20, 'x'.repeat(97)],
|
||||
@@ -1019,6 +1195,22 @@ try {
|
||||
malformedReport.firstBattleReport.completedCampVisits.length === 0,
|
||||
`Expected malformed report fields to be normalized without discarding the report: ${JSON.stringify(malformedReport)}`
|
||||
);
|
||||
const malformedReportPerformance = malformedReport.firstBattleReport?.sortiePerformance;
|
||||
assert(
|
||||
JSON.stringify(malformedReportPerformance?.selectedUnitIds) === JSON.stringify(['liu-bei']) &&
|
||||
JSON.stringify(malformedReportPerformance?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front' }) &&
|
||||
malformedReportPerformance?.unitStats.length === 1 &&
|
||||
malformedReportPerformance.unitStats[0].unitId === 'liu-bei' &&
|
||||
malformedReportPerformance.unitStats[0].hpBefore === 40 &&
|
||||
malformedReportPerformance.unitStats[0].maxHpBefore === 40 &&
|
||||
malformedReportPerformance.unitStats[0].damageDealt === 999999 &&
|
||||
malformedReportPerformance.unitStats[0].damageTaken === 0 &&
|
||||
malformedReportPerformance.unitStats[0].defeats === 3 &&
|
||||
malformedReportPerformance.unitStats[0].actions === 4 &&
|
||||
malformedReportPerformance.unitStats[0].support === 5 &&
|
||||
!Object.prototype.hasOwnProperty.call(malformedReportPerformance, 'sortieItemAssignments'),
|
||||
`Expected malformed report sortie performance to normalize numbers, dedupe entries, filter stale roster ids, and exclude supplies: ${JSON.stringify(malformedReportPerformance)}`
|
||||
);
|
||||
|
||||
storage.clear();
|
||||
storage.set(
|
||||
@@ -1134,6 +1326,25 @@ try {
|
||||
battleExp: 1
|
||||
}))
|
||||
],
|
||||
sortiePerformance: {
|
||||
selectedUnitIds: ['liu-bei', 'ghost-unit', 'liu-bei', ''],
|
||||
formationAssignments: { 'liu-bei': 'front', 'ghost-unit': 'reserve', broken: 'center' },
|
||||
unitStats: [
|
||||
sortieStatsFixture('liu-bei', {
|
||||
hpBefore: '26',
|
||||
maxHpBefore: '20',
|
||||
damageDealt: '1000000',
|
||||
damageTaken: '-3',
|
||||
defeats: '2.9',
|
||||
actions: '6',
|
||||
support: '4'
|
||||
}),
|
||||
sortieStatsFixture('liu-bei', { damageDealt: 1 }),
|
||||
sortieStatsFixture('ghost-unit', { damageDealt: 77 }),
|
||||
11
|
||||
],
|
||||
itemAssignments: { 'liu-bei': { bean: 2 } }
|
||||
},
|
||||
reserveTraining: [
|
||||
{
|
||||
unitId: 'guan-yu',
|
||||
@@ -1233,6 +1444,22 @@ try {
|
||||
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.reserveTraining?.[0].focusId === 'balanced',
|
||||
`Expected nested battle history progress arrays to filter invalid entries and normalize numbers: ${JSON.stringify(malformedHistory)}`
|
||||
);
|
||||
const malformedHistoryPerformance = malformedHistory.battleHistory['first-battle-zhuo-commandery']?.sortiePerformance;
|
||||
assert(
|
||||
JSON.stringify(malformedHistoryPerformance?.selectedUnitIds) === JSON.stringify(['liu-bei']) &&
|
||||
JSON.stringify(malformedHistoryPerformance?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front' }) &&
|
||||
malformedHistoryPerformance?.unitStats.length === 1 &&
|
||||
malformedHistoryPerformance.unitStats[0].unitId === 'liu-bei' &&
|
||||
malformedHistoryPerformance.unitStats[0].hpBefore === 26 &&
|
||||
malformedHistoryPerformance.unitStats[0].maxHpBefore === 26 &&
|
||||
malformedHistoryPerformance.unitStats[0].damageDealt === 999999 &&
|
||||
malformedHistoryPerformance.unitStats[0].damageTaken === 0 &&
|
||||
malformedHistoryPerformance.unitStats[0].defeats === 2 &&
|
||||
malformedHistoryPerformance.unitStats[0].actions === 6 &&
|
||||
malformedHistoryPerformance.unitStats[0].support === 4 &&
|
||||
!Object.prototype.hasOwnProperty.call(malformedHistoryPerformance, 'itemAssignments'),
|
||||
`Expected malformed historical sortie performance to normalize and filter roster references without persisting supplies: ${JSON.stringify(malformedHistoryPerformance)}`
|
||||
);
|
||||
|
||||
storage.clear();
|
||||
storage.set(campaignStorageKey, JSON.stringify({ version: 99, step: 'sixty-sixth-camp' }));
|
||||
@@ -1317,7 +1544,7 @@ try {
|
||||
`Expected explicitly loaded corrupted slot timestamp to be normalized: ${JSON.stringify(corruptedSlot)}`
|
||||
);
|
||||
|
||||
console.log('Verified campaign save normalization, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-growth/bond-roster/report-reference/sortie-roster/sortie-preset/latest-history/detail recovery, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.');
|
||||
console.log('Verified campaign save normalization, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-growth/bond-roster/report-reference/sortie-roster/sortie-preset/sortie-performance/latest-history/detail recovery, sortie performance legacy/deep-clone safety, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
@@ -106,6 +106,59 @@ try {
|
||||
);
|
||||
assert(!resultProbe.resultTexts.some((text) => text.includes('미달')), `Expected result screen to avoid harsh optional-goal wording: ${JSON.stringify(resultProbe.resultTexts)}`);
|
||||
|
||||
const firstResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||
const firstResultSave = await readCampaignSave(page);
|
||||
const firstResultPerformance = firstResultState?.sortieEvaluation?.snapshot;
|
||||
assert(
|
||||
firstResultState?.sortieEvaluation?.available === true &&
|
||||
firstResultState.sortieEvaluation.open === false &&
|
||||
['S', 'A', 'B', 'C', 'D'].includes(firstResultState.sortieEvaluation.grade) &&
|
||||
Number.isFinite(firstResultState.sortieEvaluation.score),
|
||||
`Expected the first result to expose a closed formation evaluation with a grade: ${JSON.stringify(firstResultState?.sortieEvaluation)}`
|
||||
);
|
||||
assert(
|
||||
firstResultPerformance?.selectedUnitIds?.length === firstResultState?.deployedAllyIds?.length &&
|
||||
firstResultPerformance.selectedUnitIds.every((unitId) => firstResultState.deployedAllyIds.includes(unitId)) &&
|
||||
firstResultPerformance.selectedUnitIds.every((unitId) => ['front', 'flank', 'support', 'reserve'].includes(firstResultPerformance.formationAssignments?.[unitId])) &&
|
||||
firstResultPerformance.unitStats?.length === firstResultPerformance.selectedUnitIds.length &&
|
||||
!Object.prototype.hasOwnProperty.call(firstResultPerformance, 'sortieItemAssignments') &&
|
||||
!Object.prototype.hasOwnProperty.call(firstResultPerformance, 'itemAssignments'),
|
||||
`Expected the first result snapshot to materialize every deployed officer and role without supplies: ${JSON.stringify(firstResultPerformance)}`
|
||||
);
|
||||
assert(
|
||||
sameJsonValue(firstResultSave.current?.firstBattleReport?.sortiePerformance, firstResultPerformance) &&
|
||||
sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortiePerformance, firstResultPerformance) &&
|
||||
sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortiePerformance, firstResultPerformance) &&
|
||||
sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortiePerformance, firstResultPerformance),
|
||||
`Expected the first formation result snapshot to synchronize across report, history, current, and slot-1 saves: ${JSON.stringify(firstResultSave)}`
|
||||
);
|
||||
|
||||
const firstEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle');
|
||||
await page.mouse.click(firstEvaluationTogglePoint.x, firstEvaluationTogglePoint.y);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true, undefined, { timeout: 30000 });
|
||||
const firstEvaluationProbe = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
return {
|
||||
evaluation: state?.sortieEvaluation,
|
||||
texts: (scene?.resultFormationReviewObjects ?? [])
|
||||
.filter((object) => object?.type === 'Text')
|
||||
.map((object) => object.text)
|
||||
};
|
||||
});
|
||||
assert(
|
||||
firstEvaluationProbe.texts.includes('이번 편성 평가') &&
|
||||
firstEvaluationProbe.texts.some((text) => text.includes('명단·역할만 저장') && text.includes('장비·보급 제외')) &&
|
||||
firstEvaluationProbe.evaluation?.presetCards?.length === 3 &&
|
||||
firstEvaluationProbe.evaluation.presetCards.every((card) => card.saved === false && card.matching === false && card.actionButtonBounds),
|
||||
`Expected the visible first-battle evaluation to explain its scope and show three empty preset actions: ${JSON.stringify(firstEvaluationProbe)}`
|
||||
);
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-formation-evaluation.png`, fullPage: true });
|
||||
await assertCanvasPainted(page, 'first battle formation evaluation');
|
||||
const firstEvaluationClosePoint = await readBattleResultEvaluationControlPoint(page, 'close');
|
||||
await page.mouse.click(firstEvaluationClosePoint.x, firstEvaluationClosePoint.y);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false, undefined, { timeout: 30000 });
|
||||
|
||||
await page.mouse.click(738, 642);
|
||||
await waitForCampAfterBattleResult(page);
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true });
|
||||
@@ -1111,6 +1164,205 @@ try {
|
||||
`Expected a non-Peach-Garden officer to receive a generic sortie role effect: ${JSON.stringify(midNextBattleProbe.units)}`
|
||||
);
|
||||
|
||||
const midResultFixture = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
const contributor = state?.units?.find((unit) =>
|
||||
unit.faction === 'ally' && ['front', 'flank', 'support'].includes(unit.formationRole)
|
||||
);
|
||||
const wounded = state?.units?.find((unit) =>
|
||||
unit.faction === 'ally' && unit.id !== contributor?.id && unit.hp > 1
|
||||
) ?? contributor;
|
||||
const liveWounded = wounded?.id ? scene?.debugUnitById?.(wounded.id) : undefined;
|
||||
if (!scene || !contributor || !liveWounded || typeof scene.statsFor !== 'function') {
|
||||
return { ready: false, contributorId: contributor?.id ?? null, woundedUnitId: wounded?.id ?? null };
|
||||
}
|
||||
|
||||
const stats = scene.statsFor(contributor.id);
|
||||
Object.assign(stats, {
|
||||
damageDealt: 137,
|
||||
damageTaken: 22,
|
||||
defeats: 3,
|
||||
actions: 5,
|
||||
support: 11,
|
||||
sortieBonusDamage: 17,
|
||||
sortiePreventedDamage: 9,
|
||||
sortieBonusHealing: 4,
|
||||
sortieExtendedBondTriggers: 2,
|
||||
intentDefeats: 1,
|
||||
intentLineBreaks: 1,
|
||||
intentGuardSuccesses: 1
|
||||
});
|
||||
liveWounded.hp = Math.max(1, liveWounded.maxHp - 7);
|
||||
return {
|
||||
ready: true,
|
||||
contributorId: contributor.id,
|
||||
woundedUnitId: liveWounded.id,
|
||||
woundedHp: liveWounded.hp,
|
||||
woundedMaxHp: liveWounded.maxHp,
|
||||
expectedStats: {
|
||||
damageDealt: 137,
|
||||
damageTaken: 22,
|
||||
defeats: 3,
|
||||
actions: 5,
|
||||
support: 11,
|
||||
sortieBonusDamage: 17,
|
||||
sortiePreventedDamage: 9,
|
||||
sortieBonusHealing: 4,
|
||||
sortieExtendedBondTriggers: 2,
|
||||
intentDefeats: 1,
|
||||
intentLineBreaks: 1,
|
||||
intentGuardSuccesses: 1
|
||||
}
|
||||
};
|
||||
});
|
||||
assert(
|
||||
midResultFixture.ready === true &&
|
||||
midResultFixture.contributorId &&
|
||||
midResultFixture.woundedUnitId &&
|
||||
midResultFixture.woundedHp < midResultFixture.woundedMaxHp,
|
||||
`Expected a deterministic contribution and recovery fixture for the mid-campaign result: ${JSON.stringify(midResultFixture)}`
|
||||
);
|
||||
|
||||
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
|
||||
await waitForBattleOutcome(page, 'victory');
|
||||
const midResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||
const midResultSaveBeforeUpdate = await readCampaignSave(page);
|
||||
const midResultPerformance = midResultState?.sortieEvaluation?.snapshot;
|
||||
const midContributorPerformance = midResultPerformance?.unitStats?.find((stats) => stats.unitId === midResultFixture.contributorId);
|
||||
const midBattleRoleById = Object.fromEntries(
|
||||
(midNextBattleProbe?.deployedAllyPositions ?? []).map((unit) => [unit.id, unit.formationRole])
|
||||
);
|
||||
const expectedMidResultPreset = {
|
||||
selectedUnitIds: [...(midResultPerformance?.selectedUnitIds ?? [])],
|
||||
formationAssignments: { ...(midResultPerformance?.formationAssignments ?? {}) }
|
||||
};
|
||||
assert(
|
||||
midResultState?.sortieEvaluation?.available === true &&
|
||||
midResultState.sortieEvaluation.open === false &&
|
||||
midResultState.sortieEvaluation.totalDamage === midResultFixture.expectedStats.damageDealt &&
|
||||
midResultState.sortieEvaluation.totalDamageTaken === midResultFixture.expectedStats.damageTaken &&
|
||||
midResultState.sortieEvaluation.totalDefeats === midResultFixture.expectedStats.defeats &&
|
||||
midResultState.sortieEvaluation.totalSupport === midResultFixture.expectedStats.support &&
|
||||
midResultState.sortieEvaluation.recoveryNeededCount > 0 &&
|
||||
midResultState.sortieEvaluation.roleCoverage < 3,
|
||||
`Expected the mid-campaign evaluation to reflect the deterministic contribution, injury, and missing role: ${JSON.stringify(midResultState?.sortieEvaluation)}`
|
||||
);
|
||||
assert(
|
||||
midContributorPerformance &&
|
||||
Object.entries(midResultFixture.expectedStats).every(([key, value]) => midContributorPerformance[key] === value) &&
|
||||
midContributorPerformance.hpBefore > 0 &&
|
||||
midContributorPerformance.maxHpBefore >= midContributorPerformance.hpBefore &&
|
||||
sameJsonValue([...(midResultPerformance?.selectedUnitIds ?? [])].sort(), [...midCampSelectedSortieUnitIds].sort()) &&
|
||||
Object.keys(midResultPerformance?.formationAssignments ?? {}).length === midCampSelectedSortieUnitIds.length &&
|
||||
midCampSelectedSortieUnitIds.every(
|
||||
(unitId) => midResultPerformance?.formationAssignments?.[unitId] === midBattleRoleById[unitId]
|
||||
),
|
||||
`Expected the saved result snapshot to retain the exact roster, roles, starting HP, and contribution stats: ${JSON.stringify(midResultPerformance)}`
|
||||
);
|
||||
assert(
|
||||
sameJsonValue(midResultSaveBeforeUpdate.current?.firstBattleReport?.sortiePerformance, midResultPerformance) &&
|
||||
sameJsonValue(midResultSaveBeforeUpdate.slot1?.firstBattleReport?.sortiePerformance, midResultPerformance) &&
|
||||
sameJsonValue(midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) &&
|
||||
sameJsonValue(midResultSaveBeforeUpdate.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) &&
|
||||
!sameJsonValue(midResultSaveBeforeUpdate.current?.sortieFormationPresets?.mobile, expectedMidResultPreset),
|
||||
`Expected the mid result snapshot to synchronize everywhere while remaining meaningfully different from the saved mobile preset: ${JSON.stringify({
|
||||
snapshot: midResultPerformance,
|
||||
mobile: midResultSaveBeforeUpdate.current?.sortieFormationPresets?.mobile
|
||||
})}`
|
||||
);
|
||||
|
||||
const midEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle');
|
||||
await page.mouse.click(midEvaluationTogglePoint.x, midEvaluationTogglePoint.y);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true, undefined, { timeout: 30000 });
|
||||
const midEvaluationProbe = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation;
|
||||
return {
|
||||
evaluation,
|
||||
texts: (scene?.resultFormationReviewObjects ?? [])
|
||||
.filter((object) => object?.type === 'Text')
|
||||
.map((object) => object.text)
|
||||
};
|
||||
});
|
||||
const midEvaluationSave = await readCampaignSave(page);
|
||||
const mobileResultCard = midEvaluationProbe.evaluation?.presetCards?.find((card) => card.id === 'mobile');
|
||||
assert(
|
||||
midEvaluationProbe.texts.includes('이번 편성 평가') &&
|
||||
midEvaluationProbe.texts.some((text) => text.includes('기동') && text.includes('갱신')) &&
|
||||
mobileResultCard?.saved === true &&
|
||||
mobileResultCard.matching === false &&
|
||||
mobileResultCard.actionButtonBounds &&
|
||||
sameJsonValue(midEvaluationSave, midResultSaveBeforeUpdate),
|
||||
`Expected opening the evaluation to expose a non-destructive mobile preset update action: ${JSON.stringify(midEvaluationProbe)}`
|
||||
);
|
||||
|
||||
const firstMobileUpdatePoint = await readBattleResultEvaluationControlPoint(page, 'preset', 'mobile');
|
||||
await page.mouse.click(firstMobileUpdatePoint.x, firstMobileUpdatePoint.y);
|
||||
await page.waitForFunction(() => {
|
||||
const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation;
|
||||
return evaluation?.open === true &&
|
||||
evaluation?.overwriteConfirmId === 'mobile' &&
|
||||
evaluation?.feedback?.includes('한 번 더');
|
||||
}, undefined, { timeout: 30000 });
|
||||
const pendingMobileUpdateState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||
const pendingMobileUpdateSave = await readCampaignSave(page);
|
||||
assert(
|
||||
pendingMobileUpdateState?.resultVisible === true &&
|
||||
pendingMobileUpdateState?.sortieEvaluation?.sourcePresetIds?.includes('mobile') === false &&
|
||||
sameJsonValue(pendingMobileUpdateSave, midResultSaveBeforeUpdate),
|
||||
`Expected the first mobile update click to request confirmation without mutating either save: ${JSON.stringify({
|
||||
evaluation: pendingMobileUpdateState?.sortieEvaluation,
|
||||
save: pendingMobileUpdateSave
|
||||
})}`
|
||||
);
|
||||
|
||||
const confirmMobileUpdatePoint = await readBattleResultEvaluationControlPoint(page, 'preset', 'mobile');
|
||||
await page.mouse.click(confirmMobileUpdatePoint.x, confirmMobileUpdatePoint.y);
|
||||
await page.waitForFunction(() => {
|
||||
const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation;
|
||||
return evaluation?.open === true &&
|
||||
evaluation?.overwriteConfirmId === null &&
|
||||
evaluation?.sourcePresetIds?.includes('mobile') &&
|
||||
evaluation?.feedback?.includes('갱신 완료');
|
||||
}, undefined, { timeout: 30000 });
|
||||
const updatedMobileResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||
const updatedMobileResultSave = await readCampaignSave(page);
|
||||
const updatedCurrentMobilePreset = updatedMobileResultSave.current?.sortieFormationPresets?.mobile;
|
||||
const updatedSlotMobilePreset = updatedMobileResultSave.slot1?.sortieFormationPresets?.mobile;
|
||||
assert(
|
||||
sameJsonValue(updatedCurrentMobilePreset, expectedMidResultPreset) &&
|
||||
sameJsonValue(updatedSlotMobilePreset, expectedMidResultPreset) &&
|
||||
JSON.stringify(Object.keys(updatedCurrentMobilePreset ?? {}).sort()) === JSON.stringify(['formationAssignments', 'selectedUnitIds']) &&
|
||||
updatedCurrentMobilePreset?.sortieItemAssignments === undefined &&
|
||||
updatedCurrentMobilePreset?.itemAssignments === undefined &&
|
||||
updatedCurrentMobilePreset?.unitStats === undefined,
|
||||
`Expected the confirmed result action to store only the evaluated roster and roles in current and slot-1 mobile presets: ${JSON.stringify(updatedMobileResultSave)}`
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(updatedMobileResultSave.current?.selectedSortieUnitIds) === JSON.stringify(midResultSaveBeforeUpdate.current?.selectedSortieUnitIds) &&
|
||||
sameJsonValue(updatedMobileResultSave.current?.sortieFormationAssignments, midResultSaveBeforeUpdate.current?.sortieFormationAssignments) &&
|
||||
sameJsonValue(updatedMobileResultSave.current?.sortieItemAssignments, midResultSaveBeforeUpdate.current?.sortieItemAssignments) &&
|
||||
sameJsonValue(updatedMobileResultSave.slot1?.selectedSortieUnitIds, midResultSaveBeforeUpdate.slot1?.selectedSortieUnitIds) &&
|
||||
sameJsonValue(updatedMobileResultSave.slot1?.sortieFormationAssignments, midResultSaveBeforeUpdate.slot1?.sortieFormationAssignments) &&
|
||||
sameJsonValue(updatedMobileResultSave.slot1?.sortieItemAssignments, midResultSaveBeforeUpdate.slot1?.sortieItemAssignments) &&
|
||||
sameJsonValue(updatedMobileResultSave.current?.firstBattleReport?.sortiePerformance, midResultPerformance) &&
|
||||
sameJsonValue(updatedMobileResultSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) &&
|
||||
sameJsonValue(updatedMobileResultSave.current?.sortieFormationPresets?.elite, midResultSaveBeforeUpdate.current?.sortieFormationPresets?.elite) &&
|
||||
sameJsonValue(updatedMobileResultSave.current?.sortieFormationPresets?.siege, midResultSaveBeforeUpdate.current?.sortieFormationPresets?.siege),
|
||||
`Expected result-to-preset update to leave the active sortie, supplies, report snapshot, and other presets unchanged: ${JSON.stringify({
|
||||
before: midResultSaveBeforeUpdate,
|
||||
after: updatedMobileResultSave
|
||||
})}`
|
||||
);
|
||||
assert(
|
||||
updatedMobileResultState?.sortieEvaluation?.presetCards?.find((card) => card.id === 'mobile')?.matching === true &&
|
||||
updatedMobileResultState?.sortieEvaluation?.feedback?.includes('장비·보급은 저장하지 않았습니다.'),
|
||||
`Expected visible completion feedback and a matching mobile result card after update: ${JSON.stringify(updatedMobileResultState?.sortieEvaluation)}`
|
||||
);
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-formation-evaluation-updated.png`, fullPage: true });
|
||||
await assertCanvasPainted(page, 'mid camp formation evaluation update');
|
||||
|
||||
await seedCampaignSave(page, {
|
||||
battleId: 'sixty-sixth-battle-wuzhang-final',
|
||||
battleTitle: '오장원 최종전',
|
||||
@@ -1430,6 +1682,47 @@ async function readCampaignSave(page) {
|
||||
});
|
||||
}
|
||||
|
||||
async function readBattleResultEvaluationControlPoint(page, control, presetId) {
|
||||
const probe = await page.evaluate(({ control, presetId }) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation;
|
||||
const canvas = document.querySelector('canvas');
|
||||
const presetCard = evaluation?.presetCards?.find((card) => card.id === presetId);
|
||||
const bounds = control === 'toggle'
|
||||
? evaluation?.toggleButtonBounds
|
||||
: control === 'close'
|
||||
? evaluation?.closeButtonBounds
|
||||
: presetCard?.actionButtonBounds;
|
||||
if (!scene || !canvas || !bounds) {
|
||||
return {
|
||||
ready: false,
|
||||
control,
|
||||
presetId: presetId ?? null,
|
||||
evaluationOpen: evaluation?.open ?? false,
|
||||
bounds: bounds ?? null
|
||||
};
|
||||
}
|
||||
|
||||
const canvasBounds = canvas.getBoundingClientRect();
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
return {
|
||||
ready: true,
|
||||
control,
|
||||
presetId: presetId ?? null,
|
||||
evaluationOpen: evaluation?.open ?? false,
|
||||
bounds,
|
||||
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
||||
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
||||
};
|
||||
}, { control, presetId });
|
||||
assert(
|
||||
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
||||
`Expected a visible Phaser battle-result evaluation ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}`
|
||||
);
|
||||
return probe;
|
||||
}
|
||||
|
||||
async function readSortieComparisonActionPoint(page) {
|
||||
const probe = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
@@ -1567,6 +1860,7 @@ async function seedCampaignSave(page, options) {
|
||||
completedCampVisits: current.completedCampVisits ?? [],
|
||||
createdAt: now
|
||||
};
|
||||
delete report.sortiePerformance;
|
||||
const settlement = {
|
||||
battleId: report.battleId,
|
||||
battleTitle: report.battleTitle,
|
||||
|
||||
Reference in New Issue
Block a user