feat: add post-battle formation review
This commit is contained in:
@@ -35,6 +35,7 @@ try {
|
|||||||
normalizeCampaignReserveTrainingAssignments,
|
normalizeCampaignReserveTrainingAssignments,
|
||||||
normalizeCampaignSortieFormationPresets,
|
normalizeCampaignSortieFormationPresets,
|
||||||
normalizeCampaignSortieItemAssignments,
|
normalizeCampaignSortieItemAssignments,
|
||||||
|
normalizeCampaignSortiePerformanceSnapshot,
|
||||||
resetCampaignState,
|
resetCampaignState,
|
||||||
saveCampaignState,
|
saveCampaignState,
|
||||||
setCampaignState,
|
setCampaignState,
|
||||||
@@ -45,6 +46,24 @@ try {
|
|||||||
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
||||||
const { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts');
|
const { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts');
|
||||||
const { normalizeSortieFormationAssignments } = await server.ssrLoadModule('/src/game/data/sortieDeployment.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();
|
storage.clear();
|
||||||
const empty = loadCampaignState();
|
const empty = loadCampaignState();
|
||||||
@@ -155,6 +174,88 @@ try {
|
|||||||
!Object.prototype.hasOwnProperty.call(cappedSortiePresets.elite, 'sortieItemAssignments'),
|
!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)}`
|
`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.clear();
|
||||||
storage.set(
|
storage.set(
|
||||||
@@ -303,6 +404,47 @@ try {
|
|||||||
completedCampVisits: [],
|
completedCampVisits: [],
|
||||||
createdAt: '2026-07-03T09:00:00.000Z'
|
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');
|
const reserveDrillTemplate = campaignRecruitUnits.find((unit) => unit.id === 'jian-yong');
|
||||||
assert(reserveDrillTemplate, 'Expected Jian Yong recruit template to exist for reserve drill assignment checks');
|
assert(reserveDrillTemplate, 'Expected Jian Yong recruit template to exist for reserve drill assignment checks');
|
||||||
storage.clear();
|
storage.clear();
|
||||||
@@ -357,6 +499,11 @@ try {
|
|||||||
repeatedSettlement.battleHistory[firstScenario.id]?.rewardGold === 100,
|
repeatedSettlement.battleHistory[firstScenario.id]?.rewardGold === 100,
|
||||||
`Expected repeated battle report settlement to avoid duplicate gold/items: ${JSON.stringify(repeatedSettlement)}`
|
`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(
|
assert(
|
||||||
repeatedSettlement.firstBattleReport?.campaignRewards?.unlocks[0]?.battleId === 'second-battle-yellow-turban-pursuit' &&
|
repeatedSettlement.firstBattleReport?.campaignRewards?.unlocks[0]?.battleId === 'second-battle-yellow-turban-pursuit' &&
|
||||||
repeatedSettlement.firstBattleReport.campaignRewards.unlocks[0].title === firstScenarioUnlocks[0]?.title &&
|
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__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 }
|
{ 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(
|
assert(
|
||||||
staleReportRefs.firstBattleReport?.bonds.length === 1 &&
|
staleReportRefs.firstBattleReport?.bonds.length === 1 &&
|
||||||
staleReportRefs.firstBattleReport.bonds[0].id === 'liu-bei__guan-yu' &&
|
staleReportRefs.firstBattleReport.bonds[0].id === 'liu-bei__guan-yu' &&
|
||||||
staleReportRefs.firstBattleReport.mvp === undefined,
|
staleReportRefs.firstBattleReport.mvp === undefined &&
|
||||||
`Expected first battle report bonds and MVP to be filtered against the recovered roster: ${JSON.stringify(staleReportRefs)}`
|
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();
|
storage.clear();
|
||||||
@@ -956,6 +1113,25 @@ try {
|
|||||||
}))
|
}))
|
||||||
],
|
],
|
||||||
mvp: { unitId: 'liu-bei', name: 'Liu Bei', damageDealt: '1000000', defeats: '2' },
|
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)],
|
itemRewards: ['Bean', 'Bean', 'Bean -2', 'Bean +0', 10, 'x'.repeat(97)],
|
||||||
campaignRewards: {
|
campaignRewards: {
|
||||||
supplies: [' Bean ', 'Bean', 'Bean -2', 'Bean +0', 20, 'x'.repeat(97)],
|
supplies: [' Bean ', 'Bean', 'Bean -2', 'Bean +0', 20, 'x'.repeat(97)],
|
||||||
@@ -1019,6 +1195,22 @@ try {
|
|||||||
malformedReport.firstBattleReport.completedCampVisits.length === 0,
|
malformedReport.firstBattleReport.completedCampVisits.length === 0,
|
||||||
`Expected malformed report fields to be normalized without discarding the report: ${JSON.stringify(malformedReport)}`
|
`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.clear();
|
||||||
storage.set(
|
storage.set(
|
||||||
@@ -1134,6 +1326,25 @@ try {
|
|||||||
battleExp: 1
|
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: [
|
reserveTraining: [
|
||||||
{
|
{
|
||||||
unitId: 'guan-yu',
|
unitId: 'guan-yu',
|
||||||
@@ -1233,6 +1444,22 @@ try {
|
|||||||
malformedHistory.battleHistory['first-battle-zhuo-commandery']?.reserveTraining?.[0].focusId === 'balanced',
|
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)}`
|
`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.clear();
|
||||||
storage.set(campaignStorageKey, JSON.stringify({ version: 99, step: 'sixty-sixth-camp' }));
|
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)}`
|
`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 {
|
} finally {
|
||||||
await server.close();
|
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)}`);
|
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 page.mouse.click(738, 642);
|
||||||
await waitForCampAfterBattleResult(page);
|
await waitForCampAfterBattleResult(page);
|
||||||
await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true });
|
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)}`
|
`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, {
|
await seedCampaignSave(page, {
|
||||||
battleId: 'sixty-sixth-battle-wuzhang-final',
|
battleId: 'sixty-sixth-battle-wuzhang-final',
|
||||||
battleTitle: '오장원 최종전',
|
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) {
|
async function readSortieComparisonActionPoint(page) {
|
||||||
const probe = await page.evaluate(() => {
|
const probe = await page.evaluate(() => {
|
||||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||||
@@ -1567,6 +1860,7 @@ async function seedCampaignSave(page, options) {
|
|||||||
completedCampVisits: current.completedCampVisits ?? [],
|
completedCampVisits: current.completedCampVisits ?? [],
|
||||||
createdAt: now
|
createdAt: now
|
||||||
};
|
};
|
||||||
|
delete report.sortiePerformance;
|
||||||
const settlement = {
|
const settlement = {
|
||||||
battleId: report.battleId,
|
battleId: report.battleId,
|
||||||
battleTitle: report.battleTitle,
|
battleTitle: report.battleTitle,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
sortieRoleEffectSummaries
|
sortieRoleEffectSummaries
|
||||||
} from '../data/sortieSynergy';
|
} from '../data/sortieSynergy';
|
||||||
import {
|
import {
|
||||||
|
campaignSortiePresetIds,
|
||||||
campaignSaveSlotCount,
|
campaignSaveSlotCount,
|
||||||
getCampaignState,
|
getCampaignState,
|
||||||
listCampaignSaveSlots,
|
listCampaignSaveSlots,
|
||||||
@@ -62,6 +63,8 @@ import {
|
|||||||
type CampaignRewardSnapshot,
|
type CampaignRewardSnapshot,
|
||||||
type CampaignState,
|
type CampaignState,
|
||||||
type CampaignSortieItemAssignments,
|
type CampaignSortieItemAssignments,
|
||||||
|
type CampaignSortiePerformanceSnapshot,
|
||||||
|
type CampaignSortiePresetId,
|
||||||
type CampaignStep
|
type CampaignStep
|
||||||
} from '../state/campaignState';
|
} from '../state/campaignState';
|
||||||
import {
|
import {
|
||||||
@@ -1178,6 +1181,15 @@ const sortieRoleEffects: Partial<Record<SortieFormationRole, {
|
|||||||
tone: 0x83d6ff
|
tone: 0x83d6ff
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const resultSortiePresetDefinitions: {
|
||||||
|
id: CampaignSortiePresetId;
|
||||||
|
label: string;
|
||||||
|
accent: number;
|
||||||
|
}[] = [
|
||||||
|
{ id: 'elite', label: '정예', accent: 0xd8b15f },
|
||||||
|
{ id: 'mobile', label: '기동', accent: 0x72a7d8 },
|
||||||
|
{ id: 'siege', label: '공성', accent: 0xc87552 }
|
||||||
|
];
|
||||||
const battleSpeedLabels: Record<BattleSpeed, string> = {
|
const battleSpeedLabels: Record<BattleSpeed, string> = {
|
||||||
normal: '보통',
|
normal: '보통',
|
||||||
fast: '빠름',
|
fast: '빠름',
|
||||||
@@ -1265,6 +1277,49 @@ type UnitBattleStats = {
|
|||||||
intentGuardSuccesses: number;
|
intentGuardSuccesses: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ResultFormationGrade = 'S' | 'A' | 'B' | 'C' | 'D';
|
||||||
|
|
||||||
|
type ResultSortieContributionTotals = {
|
||||||
|
bonusDamage: number;
|
||||||
|
preventedDamage: number;
|
||||||
|
bonusHealing: number;
|
||||||
|
extendedBondTriggers: number;
|
||||||
|
counterplays: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultFormationReview = {
|
||||||
|
score: number;
|
||||||
|
grade: ResultFormationGrade;
|
||||||
|
gradeColor: number;
|
||||||
|
gradeTextColor: string;
|
||||||
|
headline: string;
|
||||||
|
strengths: string[];
|
||||||
|
improvement: string;
|
||||||
|
objectiveAchieved: number;
|
||||||
|
objectiveTotal: number;
|
||||||
|
survivorCount: number;
|
||||||
|
deployedCount: number;
|
||||||
|
recoveryNeededCount: number;
|
||||||
|
totalDamage: number;
|
||||||
|
totalDamageTaken: number;
|
||||||
|
totalDefeats: number;
|
||||||
|
totalSupport: number;
|
||||||
|
roleCoverage: number;
|
||||||
|
activeBondCount: number;
|
||||||
|
contributionTotals: ResultSortieContributionTotals;
|
||||||
|
snapshot: CampaignSortiePerformanceSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultFormationReviewView = {
|
||||||
|
closeButton: Phaser.GameObjects.Rectangle;
|
||||||
|
presetButtons: Partial<Record<CampaignSortiePresetId, Phaser.GameObjects.Rectangle>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultButtonView = {
|
||||||
|
background: Phaser.GameObjects.Rectangle;
|
||||||
|
label: Phaser.GameObjects.Text;
|
||||||
|
};
|
||||||
|
|
||||||
type ThreatTile = {
|
type ThreatTile = {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -3195,6 +3250,12 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
private resultGaugeAnimations: ResultGaugeAnimation[] = [];
|
private resultGaugeAnimations: ResultGaugeAnimation[] = [];
|
||||||
private resultAnimationToken = 0;
|
private resultAnimationToken = 0;
|
||||||
private resultSettlementText?: Phaser.GameObjects.Text;
|
private resultSettlementText?: Phaser.GameObjects.Text;
|
||||||
|
private resultFormationReviewObjects: Phaser.GameObjects.GameObject[] = [];
|
||||||
|
private resultFormationReviewVisible = false;
|
||||||
|
private resultFormationReviewFeedback = '';
|
||||||
|
private resultPresetOverwriteConfirmId?: CampaignSortiePresetId;
|
||||||
|
private resultFormationReviewButton?: ResultButtonView;
|
||||||
|
private resultFormationReviewView?: ResultFormationReviewView;
|
||||||
private saveSlotPanelObjects: Phaser.GameObjects.GameObject[] = [];
|
private saveSlotPanelObjects: Phaser.GameObjects.GameObject[] = [];
|
||||||
private saveSlotPanelMode?: SaveSlotMode;
|
private saveSlotPanelMode?: SaveSlotMode;
|
||||||
private unitViews = new Map<string, UnitView>();
|
private unitViews = new Map<string, UnitView>();
|
||||||
@@ -3251,6 +3312,9 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
|
|
||||||
create() {
|
create() {
|
||||||
this.hideBattleResult();
|
this.hideBattleResult();
|
||||||
|
this.resultFormationReviewVisible = false;
|
||||||
|
this.resultFormationReviewFeedback = '';
|
||||||
|
this.resultPresetOverwriteConfirmId = undefined;
|
||||||
const campaign = getCampaignState();
|
const campaign = getCampaignState();
|
||||||
this.resetBattleData(campaign);
|
this.resetBattleData(campaign);
|
||||||
if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') {
|
if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') {
|
||||||
@@ -3316,6 +3380,12 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.confirmLockedTargetPreview();
|
this.confirmLockedTargetPreview();
|
||||||
});
|
});
|
||||||
this.input.keyboard?.on('keydown-ESC', () => {
|
this.input.keyboard?.on('keydown-ESC', () => {
|
||||||
|
if (this.resultFormationReviewVisible) {
|
||||||
|
soundDirector.playSelect();
|
||||||
|
this.hideResultFormationReview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.saveSlotPanelObjects.length > 0) {
|
if (this.saveSlotPanelObjects.length > 0) {
|
||||||
soundDirector.playSelect();
|
soundDirector.playSelect();
|
||||||
this.hideSaveSlotPanel();
|
this.hideSaveSlotPanel();
|
||||||
@@ -10362,6 +10432,19 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
}));
|
}));
|
||||||
this.resultSettlementText.setDepth(depth + 2);
|
this.resultSettlementText.setDepth(depth + 2);
|
||||||
|
|
||||||
|
const sortiePerformance = this.resultSortiePerformanceSnapshot();
|
||||||
|
if (sortiePerformance) {
|
||||||
|
const formationReview = this.resultFormationReview(outcome, sortiePerformance);
|
||||||
|
this.resultFormationReviewButton = this.addResultButton(
|
||||||
|
`편성 평가 ${formationReview.grade}`,
|
||||||
|
left + 470,
|
||||||
|
top + 612,
|
||||||
|
136,
|
||||||
|
() => this.toggleResultFormationReview(outcome),
|
||||||
|
depth + 3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (outcome === 'victory') {
|
if (outcome === 'victory') {
|
||||||
const isFinalBattle = battleScenario.id === 'sixty-sixth-battle-wuzhang-final';
|
const isFinalBattle = battleScenario.id === 'sixty-sixth-battle-wuzhang-final';
|
||||||
this.addResultButton(isFinalBattle ? '에필로그로' : '군영으로', left + panelWidth - 422, top + 612, 132, () => {
|
this.addResultButton(isFinalBattle ? '에필로그로' : '군영으로', left + panelWidth - 422, top + 612, 132, () => {
|
||||||
@@ -10398,6 +10481,13 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
|
|
||||||
private hideBattleResult() {
|
private hideBattleResult() {
|
||||||
this.resultAnimationToken += 1;
|
this.resultAnimationToken += 1;
|
||||||
|
this.resultFormationReviewObjects.forEach((object) => object.destroy());
|
||||||
|
this.resultFormationReviewObjects = [];
|
||||||
|
this.resultFormationReviewVisible = false;
|
||||||
|
this.resultFormationReviewFeedback = '';
|
||||||
|
this.resultPresetOverwriteConfirmId = undefined;
|
||||||
|
this.resultFormationReviewButton = undefined;
|
||||||
|
this.resultFormationReviewView = undefined;
|
||||||
this.resultObjects.forEach((object) => object.destroy());
|
this.resultObjects.forEach((object) => object.destroy());
|
||||||
this.resultObjects = [];
|
this.resultObjects = [];
|
||||||
this.resultGaugeAnimations = [];
|
this.resultGaugeAnimations = [];
|
||||||
@@ -10547,6 +10637,46 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
.sort((a, b) => b.score - a.score || b.stats.defeats - a.stats.defeats || b.stats.damageDealt - a.stats.damageDealt)[0];
|
.sort((a, b) => b.score - a.score || b.stats.defeats - a.stats.defeats || b.stats.damageDealt - a.stats.damageDealt)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resultSortiePerformanceSnapshot(): CampaignSortiePerformanceSnapshot | undefined {
|
||||||
|
if (!battleScenario.sortie) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaign = getCampaignState();
|
||||||
|
const alliedUnits = battleUnits.filter((unit) => unit.faction === 'ally');
|
||||||
|
const alliedIds = new Set(alliedUnits.map((unit) => unit.id));
|
||||||
|
const configuredIds = this.effectiveSortieUnitIds(campaign).filter((unitId) => alliedIds.has(unitId));
|
||||||
|
const selectedUnitIds = [...new Set([...configuredIds, ...alliedUnits.map((unit) => unit.id)])];
|
||||||
|
if (selectedUnitIds.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitById = new Map(alliedUnits.map((unit) => [unit.id, unit]));
|
||||||
|
const initialUnitById = new Map(initialBattleUnits.filter((unit) => unit.faction === 'ally').map((unit) => [unit.id, unit]));
|
||||||
|
const formationAssignments = selectedUnitIds.reduce<SortieFormationAssignments>((assignments, unitId) => {
|
||||||
|
const unit = unitById.get(unitId);
|
||||||
|
if (unit) {
|
||||||
|
assignments[unitId] = this.sortieRoleForUnit(unit) ?? this.defaultSortieFormationRole(unit);
|
||||||
|
}
|
||||||
|
return assignments;
|
||||||
|
}, {});
|
||||||
|
return {
|
||||||
|
selectedUnitIds,
|
||||||
|
formationAssignments,
|
||||||
|
unitStats: selectedUnitIds.map((unitId) => {
|
||||||
|
const stats = this.statsFor(unitId);
|
||||||
|
const unit = unitById.get(unitId);
|
||||||
|
const initialUnit = initialUnitById.get(unitId) ?? unit;
|
||||||
|
return {
|
||||||
|
unitId,
|
||||||
|
hpBefore: initialUnit?.hp ?? 0,
|
||||||
|
maxHpBefore: initialUnit?.maxHp ?? 0,
|
||||||
|
...stats
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private publishFirstBattleReport(outcome: BattleOutcome) {
|
private publishFirstBattleReport(outcome: BattleOutcome) {
|
||||||
const objectives = this.resultObjectives(outcome);
|
const objectives = this.resultObjectives(outcome);
|
||||||
const mvp = this.resultMvp();
|
const mvp = this.resultMvp();
|
||||||
@@ -10578,6 +10708,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
: undefined,
|
: undefined,
|
||||||
itemRewards: this.resultItemRewards(outcome),
|
itemRewards: this.resultItemRewards(outcome),
|
||||||
campaignRewards: rewardSnapshot,
|
campaignRewards: rewardSnapshot,
|
||||||
|
sortiePerformance: this.resultSortiePerformanceSnapshot(),
|
||||||
completedCampDialogues: [],
|
completedCampDialogues: [],
|
||||||
completedCampVisits: [],
|
completedCampVisits: [],
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
@@ -10643,6 +10774,417 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resultFormationReview(
|
||||||
|
outcome: BattleOutcome,
|
||||||
|
snapshot: CampaignSortiePerformanceSnapshot
|
||||||
|
): ResultFormationReview {
|
||||||
|
const unitsById = new Map(battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => [unit.id, unit]));
|
||||||
|
const deployedUnits = snapshot.selectedUnitIds
|
||||||
|
.map((unitId) => unitsById.get(unitId))
|
||||||
|
.filter((unit): unit is UnitData => Boolean(unit));
|
||||||
|
const objectives = this.resultObjectives(outcome);
|
||||||
|
const objectiveAchieved = objectives.filter((objective) => objective.achieved).length;
|
||||||
|
const survivorCount = deployedUnits.filter((unit) => unit.hp > 0).length;
|
||||||
|
const recoveryNeededCount = deployedUnits.filter((unit) => unit.hp > 0 && unit.hp < unit.maxHp).length;
|
||||||
|
const totalDamage = snapshot.unitStats.reduce((sum, stats) => sum + stats.damageDealt, 0);
|
||||||
|
const totalDamageTaken = snapshot.unitStats.reduce((sum, stats) => sum + stats.damageTaken, 0);
|
||||||
|
const totalDefeats = snapshot.unitStats.reduce((sum, stats) => sum + stats.defeats, 0);
|
||||||
|
const totalSupport = snapshot.unitStats.reduce((sum, stats) => sum + stats.support, 0);
|
||||||
|
const roleCoverage = coreSortieSynergyRoles.filter((role) =>
|
||||||
|
snapshot.selectedUnitIds.some((unitId) => snapshot.formationAssignments[unitId] === role)
|
||||||
|
).length;
|
||||||
|
const activeBondCount = this.activeSortieBonds().length;
|
||||||
|
const contributionTotals = this.sortieContributionTotals();
|
||||||
|
const objectiveRate = objectives.length > 0 ? objectiveAchieved / objectives.length : 1;
|
||||||
|
const survivalRate = deployedUnits.length > 0 ? survivorCount / deployedUnits.length : 0;
|
||||||
|
const enemyCount = battleUnits.filter((unit) => unit.faction === 'enemy').length;
|
||||||
|
const defeatRate = enemyCount > 0 ? Math.min(1, totalDefeats / enemyCount) : 1;
|
||||||
|
const contributionScore = contributionTotals.bonusDamage + contributionTotals.preventedDamage +
|
||||||
|
contributionTotals.bonusHealing + contributionTotals.extendedBondTriggers * 4 + contributionTotals.counterplays * 3;
|
||||||
|
const score = Phaser.Math.Clamp(Math.round(
|
||||||
|
(outcome === 'victory' ? 35 : 10) +
|
||||||
|
objectiveRate * 25 +
|
||||||
|
survivalRate * 20 +
|
||||||
|
defeatRate * 10 +
|
||||||
|
(roleCoverage / coreSortieSynergyRoles.length) * 5 +
|
||||||
|
Math.min(5, activeBondCount * 2) +
|
||||||
|
Math.min(5, contributionScore / 12)
|
||||||
|
), 0, 100);
|
||||||
|
const grade: ResultFormationGrade = score >= 90 ? 'S' : score >= 75 ? 'A' : score >= 60 ? 'B' : score >= 45 ? 'C' : 'D';
|
||||||
|
const gradeTone: Record<ResultFormationGrade, { color: number; text: string }> = {
|
||||||
|
S: { color: 0xd8b15f, text: '#fff0b5' },
|
||||||
|
A: { color: 0x59d18c, text: '#caffdc' },
|
||||||
|
B: { color: 0x72a7d8, text: '#d8ecff' },
|
||||||
|
C: { color: 0xc87552, text: '#ffd0bd' },
|
||||||
|
D: { color: 0x9a5b55, text: '#ffb6a6' }
|
||||||
|
};
|
||||||
|
const headlines: Record<ResultFormationGrade, string> = {
|
||||||
|
S: '빈틈없이 목표를 완수한 모범 편성',
|
||||||
|
A: '안정적으로 전선을 지켜낸 우수 편성',
|
||||||
|
B: '강점이 분명하고 보완 여지가 있는 편성',
|
||||||
|
C: '역할과 생존 계획을 다시 다듬을 편성',
|
||||||
|
D: '재도전 전 전면적인 재편이 필요한 편성'
|
||||||
|
};
|
||||||
|
const strengths = [
|
||||||
|
objectiveAchieved === objectives.length && objectives.length > 0 ? `목표 ${objectiveAchieved}/${objectives.length} 완수` : '',
|
||||||
|
survivorCount === deployedUnits.length && deployedUnits.length > 0 ? '출전 전원 생존' : '',
|
||||||
|
roleCoverage === coreSortieSynergyRoles.length ? '핵심 역할 3/3 충족' : '',
|
||||||
|
activeBondCount > 0 ? `활성 공명 ${activeBondCount}개` : '',
|
||||||
|
contributionScore > 0 ? `편성 효과 기여 ${contributionScore}` : '',
|
||||||
|
totalDefeats > 0 ? `적 ${totalDefeats}명 격파` : ''
|
||||||
|
].filter(Boolean).slice(0, 3);
|
||||||
|
const missingRoleLabels = coreSortieSynergyRoles
|
||||||
|
.filter((role) => !snapshot.selectedUnitIds.some((unitId) => snapshot.formationAssignments[unitId] === role))
|
||||||
|
.map((role) => sortieRoleEffects[role]?.label ?? role);
|
||||||
|
const improvement = outcome === 'defeat'
|
||||||
|
? '패배 조건을 먼저 차단하고 필수 무장의 생존 동선을 확보하십시오.'
|
||||||
|
: survivorCount < deployedUnits.length
|
||||||
|
? `퇴각 ${deployedUnits.length - survivorCount}명 · 전열 방호와 보급 배정을 강화하십시오.`
|
||||||
|
: missingRoleLabels.length > 0
|
||||||
|
? `${missingRoleLabels.join('·')} 공백 · 역할을 다시 배정하십시오.`
|
||||||
|
: objectiveAchieved < objectives.length
|
||||||
|
? `미달 목표 ${objectives.length - objectiveAchieved}개 · 기동 담당과 동선을 조정하십시오.`
|
||||||
|
: recoveryNeededCount > 0 && totalSupport === 0
|
||||||
|
? `회복 필요 ${recoveryNeededCount}명 · 후원 담당의 회복·보급 사용을 늘리십시오.`
|
||||||
|
: '현재 명단과 역할은 편성책에 기록해 다시 활용할 가치가 있습니다.';
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
grade,
|
||||||
|
gradeColor: gradeTone[grade].color,
|
||||||
|
gradeTextColor: gradeTone[grade].text,
|
||||||
|
headline: headlines[grade],
|
||||||
|
strengths: strengths.length > 0 ? strengths : ['전투 기록 확보'],
|
||||||
|
improvement,
|
||||||
|
objectiveAchieved,
|
||||||
|
objectiveTotal: objectives.length,
|
||||||
|
survivorCount,
|
||||||
|
deployedCount: deployedUnits.length,
|
||||||
|
recoveryNeededCount,
|
||||||
|
totalDamage,
|
||||||
|
totalDamageTaken,
|
||||||
|
totalDefeats,
|
||||||
|
totalSupport,
|
||||||
|
roleCoverage,
|
||||||
|
activeBondCount,
|
||||||
|
contributionTotals,
|
||||||
|
snapshot
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resultFormationRoleContributionLine(review: ResultFormationReview) {
|
||||||
|
const statsById = new Map(review.snapshot.unitStats.map((stats) => [stats.unitId, stats]));
|
||||||
|
return coreSortieSynergyRoles.map((role) => {
|
||||||
|
const roleStats = review.snapshot.selectedUnitIds
|
||||||
|
.filter((unitId) => review.snapshot.formationAssignments[unitId] === role)
|
||||||
|
.map((unitId) => statsById.get(unitId))
|
||||||
|
.filter((stats): stats is NonNullable<typeof stats> => Boolean(stats));
|
||||||
|
const value = role === 'front'
|
||||||
|
? roleStats.reduce((sum, stats) => sum + stats.sortiePreventedDamage + stats.sortieBonusDamage, 0)
|
||||||
|
: role === 'flank'
|
||||||
|
? roleStats.reduce((sum, stats) => sum + stats.sortieBonusDamage, 0)
|
||||||
|
: roleStats.reduce((sum, stats) => sum + stats.sortieBonusDamage + stats.sortieBonusHealing, 0);
|
||||||
|
const label = role === 'front' ? '전열' : role === 'flank' ? '돌파' : '후원';
|
||||||
|
return `${label} ${value}`;
|
||||||
|
}).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private resultFormationSnapshotMatchesPreset(presetId: CampaignSortiePresetId, snapshot: CampaignSortiePerformanceSnapshot) {
|
||||||
|
const preset = getCampaignState().sortieFormationPresets[presetId];
|
||||||
|
return Boolean(
|
||||||
|
preset &&
|
||||||
|
JSON.stringify(preset.selectedUnitIds) === JSON.stringify(snapshot.selectedUnitIds) &&
|
||||||
|
snapshot.selectedUnitIds.every((unitId) => preset.formationAssignments[unitId] === snapshot.formationAssignments[unitId])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resultFormationSourcePresetIds(snapshot: CampaignSortiePerformanceSnapshot) {
|
||||||
|
return campaignSortiePresetIds.filter((presetId) => this.resultFormationSnapshotMatchesPreset(presetId, snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleResultFormationReview(outcome: BattleOutcome) {
|
||||||
|
if (this.resultFormationReviewVisible) {
|
||||||
|
this.hideResultFormationReview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.showResultFormationReview(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
private showResultFormationReview(outcome: BattleOutcome) {
|
||||||
|
const snapshot = this.resultSortiePerformanceSnapshot();
|
||||||
|
if (!snapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.resultFormationReviewObjects.forEach((object) => object.destroy());
|
||||||
|
this.resultFormationReviewObjects = [];
|
||||||
|
this.resultFormationReviewView = undefined;
|
||||||
|
this.resultFormationReviewVisible = true;
|
||||||
|
this.resultFormationReviewButton?.label.setText('정산 보기');
|
||||||
|
|
||||||
|
const review = this.resultFormationReview(outcome, snapshot);
|
||||||
|
const depth = 130;
|
||||||
|
const width = 980;
|
||||||
|
const height = 420;
|
||||||
|
const x = Math.floor((this.scale.width - width) / 2);
|
||||||
|
const y = 200;
|
||||||
|
const bg = this.trackResultFormationReview(this.add.rectangle(x, y, width, height, 0x0b1219, 0.995));
|
||||||
|
bg.setOrigin(0);
|
||||||
|
bg.setDepth(depth);
|
||||||
|
bg.setStrokeStyle(2, review.gradeColor, 0.9);
|
||||||
|
bg.setInteractive();
|
||||||
|
|
||||||
|
this.trackResultFormationReview(this.add.text(x + 18, y + 12, '이번 편성 평가', this.resultFormationTextStyle(20, '#f2e3bf', true)))
|
||||||
|
.setDepth(depth + 1);
|
||||||
|
const closeButton = this.addResultFormationReviewButton('정산 보기', x + width - 108, y + 9, 92, 28, true, false, () => this.hideResultFormationReview(), depth + 2);
|
||||||
|
|
||||||
|
const gradeSeal = this.trackResultFormationReview(this.add.circle(x + 58, y + 82, 34, review.gradeColor, 0.22));
|
||||||
|
gradeSeal.setDepth(depth + 1);
|
||||||
|
gradeSeal.setStrokeStyle(2, review.gradeColor, 0.96);
|
||||||
|
this.trackResultFormationReview(this.add.text(x + 58, y + 82, review.grade, this.resultFormationTextStyle(34, review.gradeTextColor, true)))
|
||||||
|
.setOrigin(0.5)
|
||||||
|
.setDepth(depth + 2);
|
||||||
|
this.trackResultFormationReview(this.add.text(x + 104, y + 49, `${review.headline} · ${review.score}점`, this.resultFormationTextStyle(17, review.gradeTextColor, true)))
|
||||||
|
.setDepth(depth + 1);
|
||||||
|
this.trackResultFormationReview(this.add.text(x + 104, y + 75, `강점 · ${review.strengths.join(' · ')}`, this.resultFormationTextStyle(11, '#d4dce6', true)))
|
||||||
|
.setDepth(depth + 1);
|
||||||
|
this.trackResultFormationReview(this.add.text(x + 104, y + 96, `보완 · ${this.resultCompactText(review.improvement, 70)}`, this.resultFormationTextStyle(11, '#ffdf7b', true)))
|
||||||
|
.setDepth(depth + 1);
|
||||||
|
|
||||||
|
const metricY = y + 126;
|
||||||
|
const metricGap = 10;
|
||||||
|
const metricWidth = Math.floor((width - 36 - metricGap * 3) / 4);
|
||||||
|
const metrics = [
|
||||||
|
{ label: '목표 달성', value: `${review.objectiveAchieved}/${review.objectiveTotal}` },
|
||||||
|
{ label: '생존·회복', value: `${review.survivorCount}/${review.deployedCount} · 회복 ${review.recoveryNeededCount}` },
|
||||||
|
{ label: '전과', value: `피해 ${review.totalDamage} · 격파 ${review.totalDefeats}` },
|
||||||
|
{ label: '지원·피격', value: `지원 ${review.totalSupport} · 피격 ${review.totalDamageTaken}` }
|
||||||
|
];
|
||||||
|
metrics.forEach((metric, index) => {
|
||||||
|
this.renderResultFormationMetric(metric.label, metric.value, x + 18 + index * (metricWidth + metricGap), metricY, metricWidth, depth + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const analysisY = y + 184;
|
||||||
|
this.renderResultFormationAnalysisPanel(
|
||||||
|
'전술 판단',
|
||||||
|
[
|
||||||
|
`강점 · ${review.strengths.join(' · ')}`,
|
||||||
|
`보완 · ${this.resultCompactText(review.improvement, 62)}`
|
||||||
|
],
|
||||||
|
x + 18,
|
||||||
|
analysisY,
|
||||||
|
596,
|
||||||
|
72,
|
||||||
|
review.gradeColor,
|
||||||
|
depth + 1
|
||||||
|
);
|
||||||
|
this.renderResultFormationAnalysisPanel(
|
||||||
|
'역할·공명 기여',
|
||||||
|
[
|
||||||
|
`${this.resultFormationRoleContributionLine(review)} · 역할 ${review.roleCoverage}/3`,
|
||||||
|
`공명 ${review.activeBondCount}개 · 연장 ${review.contributionTotals.extendedBondTriggers}회 · 파훼 ${review.contributionTotals.counterplays}`
|
||||||
|
],
|
||||||
|
x + 624,
|
||||||
|
analysisY,
|
||||||
|
338,
|
||||||
|
72,
|
||||||
|
palette.blue,
|
||||||
|
depth + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
const rosterY = y + 264;
|
||||||
|
this.trackResultFormationReview(this.add.text(x + 18, rosterY, '출전 명단·역할', this.resultFormationTextStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
|
||||||
|
const visibleUnitIds = review.snapshot.selectedUnitIds.slice(0, 8);
|
||||||
|
const chipGap = 6;
|
||||||
|
const chipWidth = Math.floor((width - 36 - chipGap * Math.max(0, visibleUnitIds.length - 1)) / Math.max(1, visibleUnitIds.length));
|
||||||
|
visibleUnitIds.forEach((unitId, index) => {
|
||||||
|
const unit = battleUnits.find((candidate) => candidate.id === unitId);
|
||||||
|
const role = review.snapshot.formationAssignments[unitId];
|
||||||
|
const roleLabel = role === 'front' ? '전' : role === 'flank' ? '돌' : role === 'support' ? '후' : '예';
|
||||||
|
const chipX = x + 18 + index * (chipWidth + chipGap);
|
||||||
|
const chip = this.trackResultFormationReview(this.add.rectangle(chipX, rosterY + 23, chipWidth, 38, unit?.hp ? 0x172a22 : 0x2a1b18, 0.94));
|
||||||
|
chip.setOrigin(0);
|
||||||
|
chip.setDepth(depth + 1);
|
||||||
|
chip.setStrokeStyle(1, unit?.hp ? palette.green : palette.red, 0.58);
|
||||||
|
this.trackResultFormationReview(this.add.text(chipX + 7, rosterY + 29, `${roleLabel} ${this.resultCompactText(unit?.name ?? unitId, 7)}`, this.resultFormationTextStyle(10, '#f2e3bf', true))).setDepth(depth + 2);
|
||||||
|
this.trackResultFormationReview(this.add.text(chipX + 7, rosterY + 45, `HP ${unit?.hp ?? 0}/${unit?.maxHp ?? 0}`, this.resultFormationTextStyle(9, unit?.hp ? '#a8ffd0' : '#ffb6a6', true))).setDepth(depth + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const presetTitleY = y + 337;
|
||||||
|
this.trackResultFormationReview(this.add.text(x + 18, presetTitleY, '이번 편성을 편성책에 반영', this.resultFormationTextStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
|
||||||
|
this.trackResultFormationReview(this.add.text(x + 192, presetTitleY + 2, '명단·역할만 저장 · 장비·보급 제외', this.resultFormationTextStyle(10, '#9fb0bf', true))).setDepth(depth + 1);
|
||||||
|
const campaign = getCampaignState();
|
||||||
|
const sourcePresetIds = this.resultFormationSourcePresetIds(review.snapshot);
|
||||||
|
const presetButtons: ResultFormationReviewView['presetButtons'] = {};
|
||||||
|
const presetGap = 10;
|
||||||
|
const presetWidth = Math.floor((width - 36 - presetGap * 2) / 3);
|
||||||
|
resultSortiePresetDefinitions.forEach((definition, index) => {
|
||||||
|
const preset = campaign.sortieFormationPresets[definition.id];
|
||||||
|
const matching = sourcePresetIds.includes(definition.id);
|
||||||
|
const confirming = this.resultPresetOverwriteConfirmId === definition.id;
|
||||||
|
const cardX = x + 18 + index * (presetWidth + presetGap);
|
||||||
|
const label = matching
|
||||||
|
? `${definition.label} · 현재와 동일`
|
||||||
|
: preset
|
||||||
|
? `${definition.label} · 저장 ${preset.selectedUnitIds.length}명`
|
||||||
|
: `${definition.label} · 비어 있음`;
|
||||||
|
const actionLabel = matching ? '저장됨' : confirming ? '갱신 확정' : preset ? '갱신' : '저장';
|
||||||
|
const button = this.addResultFormationReviewButton(
|
||||||
|
`${label} ${actionLabel}`,
|
||||||
|
cardX,
|
||||||
|
presetTitleY + 24,
|
||||||
|
presetWidth,
|
||||||
|
42,
|
||||||
|
!matching,
|
||||||
|
confirming || matching,
|
||||||
|
() => this.requestResultFormationPresetUpdate(definition.id, outcome),
|
||||||
|
depth + 2,
|
||||||
|
definition.accent
|
||||||
|
);
|
||||||
|
presetButtons[definition.id] = button;
|
||||||
|
});
|
||||||
|
|
||||||
|
const feedback = this.resultFormationReviewFeedback || '평가를 비교한 뒤 저장할 편성책을 선택하십시오.';
|
||||||
|
const feedbackColor = this.resultPresetOverwriteConfirmId
|
||||||
|
? '#ffdf7b'
|
||||||
|
: this.resultFormationReviewFeedback
|
||||||
|
? '#a8ffd0'
|
||||||
|
: '#9fb0bf';
|
||||||
|
this.trackResultFormationReview(this.add.text(x + 18, y + height - 3, this.resultCompactText(feedback, 78), this.resultFormationTextStyle(10, feedbackColor, true)))
|
||||||
|
.setOrigin(0, 1)
|
||||||
|
.setDepth(depth + 1);
|
||||||
|
this.resultFormationReviewView = { closeButton, presetButtons };
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideResultFormationReview() {
|
||||||
|
this.resultFormationReviewObjects.forEach((object) => object.destroy());
|
||||||
|
this.resultFormationReviewObjects = [];
|
||||||
|
this.resultFormationReviewVisible = false;
|
||||||
|
this.resultFormationReviewFeedback = '';
|
||||||
|
this.resultPresetOverwriteConfirmId = undefined;
|
||||||
|
this.resultFormationReviewView = undefined;
|
||||||
|
if (this.battleOutcome) {
|
||||||
|
const snapshot = this.resultSortiePerformanceSnapshot();
|
||||||
|
if (snapshot) {
|
||||||
|
this.resultFormationReviewButton?.label.setText(`편성 평가 ${this.resultFormationReview(this.battleOutcome, snapshot).grade}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestResultFormationPresetUpdate(presetId: CampaignSortiePresetId, outcome: BattleOutcome) {
|
||||||
|
const snapshot = this.resultSortiePerformanceSnapshot();
|
||||||
|
if (!snapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const review = this.resultFormationReview(outcome, snapshot);
|
||||||
|
const campaign = getCampaignState();
|
||||||
|
const existing = campaign.sortieFormationPresets[presetId];
|
||||||
|
const definition = resultSortiePresetDefinitions.find((candidate) => candidate.id === presetId) ?? resultSortiePresetDefinitions[0];
|
||||||
|
if (this.resultFormationSnapshotMatchesPreset(presetId, review.snapshot)) {
|
||||||
|
this.resultFormationReviewFeedback = `${definition.label} 편성책이 이미 이번 명단·역할과 같습니다.`;
|
||||||
|
this.showResultFormationReview(outcome);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing && this.resultPresetOverwriteConfirmId !== presetId) {
|
||||||
|
this.resultPresetOverwriteConfirmId = presetId;
|
||||||
|
this.resultFormationReviewFeedback = `${definition.label} 편성책을 갱신하려면 한 번 더 누르십시오.`;
|
||||||
|
soundDirector.playSelect();
|
||||||
|
this.showResultFormationReview(outcome);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
campaign.sortieFormationPresets = {
|
||||||
|
...campaign.sortieFormationPresets,
|
||||||
|
[presetId]: {
|
||||||
|
selectedUnitIds: [...review.snapshot.selectedUnitIds],
|
||||||
|
formationAssignments: { ...review.snapshot.formationAssignments }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
saveCampaignState(campaign);
|
||||||
|
this.resultPresetOverwriteConfirmId = undefined;
|
||||||
|
this.resultFormationReviewFeedback = `${definition.label} 편성책 갱신 완료 · 장비·보급은 저장하지 않았습니다.`;
|
||||||
|
soundDirector.playSelect();
|
||||||
|
this.showResultFormationReview(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderResultFormationMetric(label: string, value: string, x: number, y: number, width: number, depth: number) {
|
||||||
|
const bg = this.trackResultFormationReview(this.add.rectangle(x, y, width, 48, 0x111c26, 0.96));
|
||||||
|
bg.setOrigin(0);
|
||||||
|
bg.setDepth(depth);
|
||||||
|
bg.setStrokeStyle(1, palette.gold, 0.38);
|
||||||
|
this.trackResultFormationReview(this.add.text(x + 10, y + 7, label, this.resultFormationTextStyle(10, '#9fb0bf', true))).setDepth(depth + 1);
|
||||||
|
this.trackResultFormationReview(this.add.text(x + width - 10, y + 25, this.resultCompactText(value, 24), this.resultFormationTextStyle(12, '#f2e3bf', true)))
|
||||||
|
.setOrigin(1, 0)
|
||||||
|
.setDepth(depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderResultFormationAnalysisPanel(
|
||||||
|
title: string,
|
||||||
|
lines: string[],
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
accent: number,
|
||||||
|
depth: number
|
||||||
|
) {
|
||||||
|
const bg = this.trackResultFormationReview(this.add.rectangle(x, y, width, height, 0x101820, 0.96));
|
||||||
|
bg.setOrigin(0);
|
||||||
|
bg.setDepth(depth);
|
||||||
|
bg.setStrokeStyle(1, accent, 0.5);
|
||||||
|
this.trackResultFormationReview(this.add.text(x + 12, y + 8, title, this.resultFormationTextStyle(12, '#f2e3bf', true))).setDepth(depth + 1);
|
||||||
|
lines.slice(0, 2).forEach((line, index) => {
|
||||||
|
this.trackResultFormationReview(this.add.text(x + 12, y + 30 + index * 18, this.resultCompactText(line, width > 500 ? 72 : 42), this.resultFormationTextStyle(10, index === 0 ? '#d4dce6' : '#ffdf7b', true)))
|
||||||
|
.setDepth(depth + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private addResultFormationReviewButton(
|
||||||
|
label: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
enabled: boolean,
|
||||||
|
active: boolean,
|
||||||
|
action: () => void,
|
||||||
|
depth: number,
|
||||||
|
accent: number = palette.gold
|
||||||
|
) {
|
||||||
|
const fill = active ? 0x263a2d : enabled ? 0x1a2630 : 0x111820;
|
||||||
|
const button = this.trackResultFormationReview(this.add.rectangle(x, y, width, height, fill, enabled || active ? 0.98 : 0.6));
|
||||||
|
button.setOrigin(0);
|
||||||
|
button.setDepth(depth);
|
||||||
|
button.setStrokeStyle(1, active ? palette.green : enabled ? accent : 0x53606c, active ? 0.82 : enabled ? 0.62 : 0.3);
|
||||||
|
if (enabled) {
|
||||||
|
button.setInteractive({ useHandCursor: true });
|
||||||
|
button.on('pointerover', () => button.setFillStyle(active ? 0x365044 : 0x283947, 1).setStrokeStyle(1, palette.gold, 0.94));
|
||||||
|
button.on('pointerout', () => button.setFillStyle(fill, 0.98).setStrokeStyle(1, active ? palette.green : accent, active ? 0.82 : 0.62));
|
||||||
|
button.on('pointerdown', action);
|
||||||
|
}
|
||||||
|
this.trackResultFormationReview(this.add.text(x + width / 2, y + height / 2, this.resultCompactText(label, 28), this.resultFormationTextStyle(10, enabled || active ? '#f2e3bf' : '#77818c', true)))
|
||||||
|
.setOrigin(0.5)
|
||||||
|
.setDepth(depth + 1);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resultFormationTextStyle(fontSize: number, color: string, bold = false): Phaser.Types.GameObjects.Text.TextStyle {
|
||||||
|
return {
|
||||||
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
color,
|
||||||
|
fontStyle: bold ? '700' : '400'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resultCompactText(value: string, maxLength: number) {
|
||||||
|
return value.length <= maxLength ? value : `${value.slice(0, Math.max(0, maxLength - 1))}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackResultFormationReview<T extends Phaser.GameObjects.GameObject>(object: T) {
|
||||||
|
this.resultFormationReviewObjects.push(object);
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
private uniqueRewardLabels(labels: string[]) {
|
private uniqueRewardLabels(labels: string[]) {
|
||||||
return [...new Set(labels.map((label) => label.trim()).filter(Boolean))];
|
return [...new Set(labels.map((label) => label.trim()).filter(Boolean))];
|
||||||
}
|
}
|
||||||
@@ -11523,6 +12065,7 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
text.setDepth(depth + 1);
|
text.setDepth(depth + 1);
|
||||||
text.setInteractive({ useHandCursor: true });
|
text.setInteractive({ useHandCursor: true });
|
||||||
text.on('pointerdown', action);
|
text.on('pointerdown', action);
|
||||||
|
return { background: bg, label: text };
|
||||||
}
|
}
|
||||||
|
|
||||||
private trackResultObject<T extends Phaser.GameObjects.GameObject>(object: T) {
|
private trackResultObject<T extends Phaser.GameObjects.GameObject>(object: T) {
|
||||||
@@ -13483,7 +14026,9 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const campaign = loadCampaignState(slot);
|
const campaign = loadCampaignState(slot);
|
||||||
|
this.launchSortieUnitIds = this.normalizeLaunchSortieUnitIds(campaign.selectedSortieUnitIds);
|
||||||
this.launchSortieFormationAssignments = normalizeSortieFormationAssignments(campaign.sortieFormationAssignments);
|
this.launchSortieFormationAssignments = normalizeSortieFormationAssignments(campaign.sortieFormationAssignments);
|
||||||
|
this.launchSortieItemAssignments = this.normalizeLaunchSortieItemAssignments(campaign.sortieItemAssignments);
|
||||||
this.applyBattleSaveState(state);
|
this.applyBattleSaveState(state);
|
||||||
this.renderSituationPanel(`슬롯 ${slot}의 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`);
|
this.renderSituationPanel(`슬롯 ${slot}의 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -19899,12 +20444,27 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
this.renderRosterPanel(this.rosterTab, text);
|
this.renderRosterPanel(this.rosterTab, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resultObjectBoundsDebug(object?: Phaser.GameObjects.Rectangle) {
|
||||||
|
if (!object?.active) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const bounds = object.getBounds();
|
||||||
|
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
|
||||||
|
}
|
||||||
|
|
||||||
getDebugState() {
|
getDebugState() {
|
||||||
const campaign = getCampaignState();
|
const campaign = getCampaignState();
|
||||||
const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign);
|
const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign);
|
||||||
const sortieFormationAssignments = this.effectiveSortieFormationAssignments(campaign);
|
const sortieFormationAssignments = this.effectiveSortieFormationAssignments(campaign);
|
||||||
const sortieItemAssignments = this.effectiveSortieItemAssignments(campaign);
|
const sortieItemAssignments = this.effectiveSortieItemAssignments(campaign);
|
||||||
const deployedAllyIds = battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => unit.id);
|
const deployedAllyIds = battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => unit.id);
|
||||||
|
const resultSortiePerformance = this.battleOutcome ? this.resultSortiePerformanceSnapshot() : undefined;
|
||||||
|
const resultFormationReview = this.battleOutcome && resultSortiePerformance
|
||||||
|
? this.resultFormationReview(this.battleOutcome, resultSortiePerformance)
|
||||||
|
: undefined;
|
||||||
|
const resultSourcePresetIds = resultFormationReview
|
||||||
|
? this.resultFormationSourcePresetIds(resultFormationReview.snapshot)
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scene: this.scene.key,
|
scene: this.scene.key,
|
||||||
@@ -19940,6 +20500,46 @@ export class BattleScene extends Phaser.Scene {
|
|||||||
saveSlotPanelVisible: this.saveSlotPanelObjects.length > 0,
|
saveSlotPanelVisible: this.saveSlotPanelObjects.length > 0,
|
||||||
saveSlotPanelMode: this.saveSlotPanelMode ?? null,
|
saveSlotPanelMode: this.saveSlotPanelMode ?? null,
|
||||||
resultVisible: this.resultObjects.length > 0,
|
resultVisible: this.resultObjects.length > 0,
|
||||||
|
sortieEvaluation: resultFormationReview
|
||||||
|
? {
|
||||||
|
available: true,
|
||||||
|
open: this.resultFormationReviewVisible,
|
||||||
|
grade: resultFormationReview.grade,
|
||||||
|
score: resultFormationReview.score,
|
||||||
|
headline: resultFormationReview.headline,
|
||||||
|
strengths: [...resultFormationReview.strengths],
|
||||||
|
improvement: resultFormationReview.improvement,
|
||||||
|
objectiveAchieved: resultFormationReview.objectiveAchieved,
|
||||||
|
objectiveTotal: resultFormationReview.objectiveTotal,
|
||||||
|
survivorCount: resultFormationReview.survivorCount,
|
||||||
|
deployedCount: resultFormationReview.deployedCount,
|
||||||
|
recoveryNeededCount: resultFormationReview.recoveryNeededCount,
|
||||||
|
totalDamage: resultFormationReview.totalDamage,
|
||||||
|
totalDamageTaken: resultFormationReview.totalDamageTaken,
|
||||||
|
totalDefeats: resultFormationReview.totalDefeats,
|
||||||
|
totalSupport: resultFormationReview.totalSupport,
|
||||||
|
roleCoverage: resultFormationReview.roleCoverage,
|
||||||
|
activeBondCount: resultFormationReview.activeBondCount,
|
||||||
|
contributionTotals: { ...resultFormationReview.contributionTotals },
|
||||||
|
snapshot: {
|
||||||
|
selectedUnitIds: [...resultFormationReview.snapshot.selectedUnitIds],
|
||||||
|
formationAssignments: { ...resultFormationReview.snapshot.formationAssignments },
|
||||||
|
unitStats: resultFormationReview.snapshot.unitStats.map((stats) => ({ ...stats }))
|
||||||
|
},
|
||||||
|
sourcePresetIds: [...resultSourcePresetIds],
|
||||||
|
overwriteConfirmId: this.resultPresetOverwriteConfirmId ?? null,
|
||||||
|
feedback: this.resultFormationReviewFeedback,
|
||||||
|
toggleButtonBounds: this.resultObjectBoundsDebug(this.resultFormationReviewButton?.background),
|
||||||
|
closeButtonBounds: this.resultObjectBoundsDebug(this.resultFormationReviewView?.closeButton),
|
||||||
|
presetCards: resultSortiePresetDefinitions.map((definition) => ({
|
||||||
|
id: definition.id,
|
||||||
|
label: definition.label,
|
||||||
|
saved: Boolean(campaign.sortieFormationPresets[definition.id]),
|
||||||
|
matching: resultSourcePresetIds.includes(definition.id),
|
||||||
|
actionButtonBounds: this.resultObjectBoundsDebug(this.resultFormationReviewView?.presetButtons[definition.id])
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
: null,
|
||||||
turnPromptVisible: this.turnPromptObjects.length > 0,
|
turnPromptVisible: this.turnPromptObjects.length > 0,
|
||||||
turnPromptMode: this.turnPromptMode ?? null,
|
turnPromptMode: this.turnPromptMode ?? null,
|
||||||
pointerFeedbackVisible: Boolean(this.pointerFeedbackMarker?.visible),
|
pointerFeedbackVisible: Boolean(this.pointerFeedbackMarker?.visible),
|
||||||
|
|||||||
@@ -44,6 +44,30 @@ export type CampaignRewardSnapshot = {
|
|||||||
note?: string;
|
note?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignSortieUnitPerformanceSnapshot = {
|
||||||
|
unitId: string;
|
||||||
|
hpBefore: number;
|
||||||
|
maxHpBefore: number;
|
||||||
|
damageDealt: number;
|
||||||
|
damageTaken: number;
|
||||||
|
defeats: number;
|
||||||
|
actions: number;
|
||||||
|
support: number;
|
||||||
|
sortieBonusDamage: number;
|
||||||
|
sortiePreventedDamage: number;
|
||||||
|
sortieBonusHealing: number;
|
||||||
|
sortieExtendedBondTriggers: number;
|
||||||
|
intentDefeats: number;
|
||||||
|
intentLineBreaks: number;
|
||||||
|
intentGuardSuccesses: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignSortiePerformanceSnapshot = {
|
||||||
|
selectedUnitIds: string[];
|
||||||
|
formationAssignments: SortieFormationAssignments;
|
||||||
|
unitStats: CampaignSortieUnitPerformanceSnapshot[];
|
||||||
|
};
|
||||||
|
|
||||||
export type FirstBattleReport = {
|
export type FirstBattleReport = {
|
||||||
battleId: string;
|
battleId: string;
|
||||||
battleTitle: string;
|
battleTitle: string;
|
||||||
@@ -58,6 +82,7 @@ export type FirstBattleReport = {
|
|||||||
mvp?: CampMvpSnapshot;
|
mvp?: CampMvpSnapshot;
|
||||||
itemRewards: string[];
|
itemRewards: string[];
|
||||||
campaignRewards?: CampaignRewardSnapshot;
|
campaignRewards?: CampaignRewardSnapshot;
|
||||||
|
sortiePerformance?: CampaignSortiePerformanceSnapshot;
|
||||||
completedCampDialogues: string[];
|
completedCampDialogues: string[];
|
||||||
completedCampVisits: string[];
|
completedCampVisits: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -320,6 +345,7 @@ export type CampaignBattleSettlement = {
|
|||||||
units: CampaignUnitProgressSnapshot[];
|
units: CampaignUnitProgressSnapshot[];
|
||||||
bonds: CampaignBondProgressSnapshot[];
|
bonds: CampaignBondProgressSnapshot[];
|
||||||
reserveTraining?: CampaignReserveTrainingSnapshot[];
|
reserveTraining?: CampaignReserveTrainingSnapshot[];
|
||||||
|
sortiePerformance?: CampaignSortiePerformanceSnapshot;
|
||||||
completedAt: string;
|
completedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1117,11 +1143,16 @@ function normalizeLatestBattleId(value: unknown, battleHistory: Record<string, C
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rosterUnitIds: Set<string>): FirstBattleReport {
|
function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rosterUnitIds: Set<string>): FirstBattleReport {
|
||||||
|
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance, rosterUnitIds);
|
||||||
const filtered = {
|
const filtered = {
|
||||||
...report,
|
...report,
|
||||||
units: report.units.filter((unit) => unit.faction !== 'ally' || rosterUnitIds.has(unit.id)),
|
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: report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId))),
|
||||||
|
...(sortiePerformance ? { sortiePerformance } : {})
|
||||||
};
|
};
|
||||||
|
if (!sortiePerformance) {
|
||||||
|
delete filtered.sortiePerformance;
|
||||||
|
}
|
||||||
if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) {
|
if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) {
|
||||||
delete filtered.mvp;
|
delete filtered.mvp;
|
||||||
}
|
}
|
||||||
@@ -1134,12 +1165,17 @@ function filterBattleHistoryRosterReferences(
|
|||||||
bondIds: Set<string>
|
bondIds: Set<string>
|
||||||
): Record<string, CampaignBattleSettlement> {
|
): Record<string, CampaignBattleSettlement> {
|
||||||
return Object.entries(history).reduce<Record<string, CampaignBattleSettlement>>((filteredHistory, [battleId, settlement]) => {
|
return Object.entries(history).reduce<Record<string, CampaignBattleSettlement>>((filteredHistory, [battleId, settlement]) => {
|
||||||
|
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance, rosterUnitIds);
|
||||||
filteredHistory[battleId] = {
|
filteredHistory[battleId] = {
|
||||||
...settlement,
|
...settlement,
|
||||||
units: settlement.units.filter((unit) => rosterUnitIds.has(unit.unitId)),
|
units: settlement.units.filter((unit) => rosterUnitIds.has(unit.unitId)),
|
||||||
bonds: bondIds.size > 0 ? settlement.bonds.filter((bond) => bondIds.has(bond.id)) : settlement.bonds,
|
bonds: bondIds.size > 0 ? settlement.bonds.filter((bond) => bondIds.has(bond.id)) : settlement.bonds,
|
||||||
reserveTraining: settlement.reserveTraining?.filter((entry) => rosterUnitIds.has(entry.unitId))
|
reserveTraining: settlement.reserveTraining?.filter((entry) => rosterUnitIds.has(entry.unitId)),
|
||||||
|
...(sortiePerformance ? { sortiePerformance } : {})
|
||||||
};
|
};
|
||||||
|
if (!sortiePerformance) {
|
||||||
|
delete filteredHistory[battleId].sortiePerformance;
|
||||||
|
}
|
||||||
return filteredHistory;
|
return filteredHistory;
|
||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
@@ -1163,6 +1199,8 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
battleId,
|
battleId,
|
||||||
battleTitle,
|
battleTitle,
|
||||||
@@ -1174,6 +1212,7 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle
|
|||||||
units: normalizeLimitedArray(settlement.units, normalizeCampaignUnitProgressSnapshot, maxCampaignBattleUnitEntries),
|
units: normalizeLimitedArray(settlement.units, normalizeCampaignUnitProgressSnapshot, maxCampaignBattleUnitEntries),
|
||||||
bonds: normalizeLimitedArray(settlement.bonds, normalizeCampaignBondProgressSnapshot, maxCampaignBattleBondEntries),
|
bonds: normalizeLimitedArray(settlement.bonds, normalizeCampaignBondProgressSnapshot, maxCampaignBattleBondEntries),
|
||||||
reserveTraining: normalizeLimitedArray(settlement.reserveTraining, normalizeReserveTrainingSnapshot, maxCampaignReserveTrainingEntries),
|
reserveTraining: normalizeLimitedArray(settlement.reserveTraining, normalizeReserveTrainingSnapshot, maxCampaignReserveTrainingEntries),
|
||||||
|
...(sortiePerformance ? { sortiePerformance } : {}),
|
||||||
completedAt
|
completedAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1355,6 +1394,60 @@ export function normalizeCampaignSortieFormationPresets(value: unknown, allowedU
|
|||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeCampaignSortiePerformanceSnapshot(
|
||||||
|
value: unknown,
|
||||||
|
allowedUnitIds?: Set<string>
|
||||||
|
): CampaignSortiePerformanceSnapshot | undefined {
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedUnitIds = uniqueStrings(value.selectedUnitIds)
|
||||||
|
.filter((unitId) => !allowedUnitIds || allowedUnitIds.has(unitId))
|
||||||
|
.slice(0, maxCampaignSortieAssignmentUnits);
|
||||||
|
if (selectedUnitIds.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIdSet = new Set(selectedUnitIds);
|
||||||
|
const unitStats = uniqueByKey(
|
||||||
|
normalizeLimitedArray(value.unitStats, (entry): CampaignSortieUnitPerformanceSnapshot | undefined => {
|
||||||
|
if (!isPlainObject(entry)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const unitId = normalizeKeyString(entry.unitId);
|
||||||
|
if (!unitId || !selectedIdSet.has(unitId)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const hpBefore = normalizeNonNegativeInteger(entry.hpBefore);
|
||||||
|
return {
|
||||||
|
unitId,
|
||||||
|
hpBefore,
|
||||||
|
maxHpBefore: Math.max(hpBefore, normalizeNonNegativeInteger(entry.maxHpBefore)),
|
||||||
|
damageDealt: normalizeNonNegativeInteger(entry.damageDealt),
|
||||||
|
damageTaken: normalizeNonNegativeInteger(entry.damageTaken),
|
||||||
|
defeats: normalizeNonNegativeInteger(entry.defeats),
|
||||||
|
actions: normalizeNonNegativeInteger(entry.actions),
|
||||||
|
support: normalizeNonNegativeInteger(entry.support),
|
||||||
|
sortieBonusDamage: normalizeNonNegativeInteger(entry.sortieBonusDamage),
|
||||||
|
sortiePreventedDamage: normalizeNonNegativeInteger(entry.sortiePreventedDamage),
|
||||||
|
sortieBonusHealing: normalizeNonNegativeInteger(entry.sortieBonusHealing),
|
||||||
|
sortieExtendedBondTriggers: normalizeNonNegativeInteger(entry.sortieExtendedBondTriggers),
|
||||||
|
intentDefeats: normalizeNonNegativeInteger(entry.intentDefeats),
|
||||||
|
intentLineBreaks: normalizeNonNegativeInteger(entry.intentLineBreaks),
|
||||||
|
intentGuardSuccesses: normalizeNonNegativeInteger(entry.intentGuardSuccesses)
|
||||||
|
};
|
||||||
|
}, maxCampaignSortieAssignmentUnits),
|
||||||
|
(entry) => entry.unitId
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedUnitIds,
|
||||||
|
formationAssignments: normalizeSortieFormationAssignments(recordOrEmpty(value.formationAssignments), selectedIdSet),
|
||||||
|
unitStats
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeReserveTrainingFocusId(focusId?: string): CampaignReserveTrainingFocusId {
|
function normalizeReserveTrainingFocusId(focusId?: string): CampaignReserveTrainingFocusId {
|
||||||
return isReserveTrainingFocusId(focusId)
|
return isReserveTrainingFocusId(focusId)
|
||||||
? focusId as CampaignReserveTrainingFocusId
|
? focusId as CampaignReserveTrainingFocusId
|
||||||
@@ -1392,6 +1485,8 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
battleId,
|
battleId,
|
||||||
battleTitle,
|
battleTitle,
|
||||||
@@ -1409,6 +1504,7 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
|
|||||||
isPlainObject(report.campaignRewards) ? report.campaignRewards as CampaignRewardSnapshot : undefined,
|
isPlainObject(report.campaignRewards) ? report.campaignRewards as CampaignRewardSnapshot : undefined,
|
||||||
battleId
|
battleId
|
||||||
),
|
),
|
||||||
|
...(sortiePerformance ? { sortiePerformance } : {}),
|
||||||
completedCampDialogues: uniqueStrings(report.completedCampDialogues),
|
completedCampDialogues: uniqueStrings(report.completedCampDialogues),
|
||||||
completedCampVisits: uniqueStrings(report.completedCampVisits),
|
completedCampVisits: uniqueStrings(report.completedCampVisits),
|
||||||
createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString()
|
createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString()
|
||||||
@@ -1643,6 +1739,15 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp
|
|||||||
battleExp: bond.battleExp
|
battleExp: bond.battleExp
|
||||||
})),
|
})),
|
||||||
reserveTraining: reserveTraining.map((entry) => ({ ...entry })),
|
reserveTraining: reserveTraining.map((entry) => ({ ...entry })),
|
||||||
|
...(report.sortiePerformance
|
||||||
|
? {
|
||||||
|
sortiePerformance: {
|
||||||
|
selectedUnitIds: [...report.sortiePerformance.selectedUnitIds],
|
||||||
|
formationAssignments: { ...report.sortiePerformance.formationAssignments },
|
||||||
|
unitStats: report.sortiePerformance.unitStats.map((stats) => ({ ...stats }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
completedAt: report.createdAt
|
completedAt: report.createdAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user