feat: add post-battle formation review

This commit is contained in:
2026-07-10 22:04:03 +09:00
parent 7ba51d9243
commit 78669d3c6e
4 changed files with 1232 additions and 6 deletions

View File

@@ -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();
}