feat: add sortie formation history

This commit is contained in:
2026-07-11 00:19:02 +09:00
parent f3d0127ab7
commit 0920039b9c
6 changed files with 1768 additions and 30 deletions

View File

@@ -36,6 +36,7 @@ try {
normalizeCampaignSortieFormationPresets,
normalizeCampaignSortieItemAssignments,
normalizeCampaignSortiePerformanceSnapshot,
normalizeCampaignSortieReviewSnapshot,
resetCampaignState,
saveCampaignState,
setCampaignState,
@@ -256,6 +257,49 @@ try {
normalizeCampaignSortiePerformanceSnapshot({ selectedUnitIds: [], unitStats: [] }) === undefined,
`Expected sortie performance snapshots to stay capped and empty snapshots to be discarded: ${JSON.stringify(cappedSortiePerformance)}`
);
const normalizedSortieReview = normalizeCampaignSortieReviewSnapshot(
{
version: 1,
score: '88.9',
grade: 'D',
activeBondCount: '999',
enemyCount: '1000',
sourcePresetIds: ['siege', 'elite', 'siege', 'ghost', 'mobile', 7],
sortieItemAssignments: { 'liu-bei': { bean: 2 } }
},
normalizedSortiePerformance
);
assert(
normalizedSortieReview?.version === 1 &&
normalizedSortieReview.score === 88 &&
normalizedSortieReview.grade === 'A' &&
normalizedSortieReview.activeBondCount === 96 &&
normalizedSortieReview.enemyCount === 96 &&
JSON.stringify(normalizedSortieReview.sourcePresetIds) === JSON.stringify(['elite', 'mobile', 'siege']) &&
!Object.prototype.hasOwnProperty.call(normalizedSortieReview, 'sortieItemAssignments'),
`Expected sortie reviews to clamp evaluator inputs, derive grade from score, canonicalize known preset ids, and exclude unrelated fields: ${JSON.stringify(normalizedSortieReview)}`
);
assert(
normalizeCampaignSortieReviewSnapshot({ ...normalizedSortieReview, version: 2 }, normalizedSortiePerformance) === undefined &&
normalizeCampaignSortieReviewSnapshot({ score: 88, grade: 'A', sourcePresetIds: ['elite'] }, normalizedSortiePerformance) === undefined &&
normalizeCampaignSortieReviewSnapshot(normalizedSortieReview) === undefined &&
normalizeCampaignSortieReviewSnapshot(normalizedSortieReview, {
selectedUnitIds: ['liu-bei'],
formationAssignments: {},
unitStats: []
}) === undefined,
'Expected sortie reviews with unsupported/missing nested versions or missing/incomplete performance snapshots to be discarded'
);
const sortieReviewGradeBoundaries = [[90, 'S'], [75, 'A'], [60, 'B'], [45, 'C'], [44, 'D']];
assert(
sortieReviewGradeBoundaries.every(([score, grade]) => (
normalizeCampaignSortieReviewSnapshot(
{ ...normalizedSortieReview, score, grade: 'D' },
normalizedSortiePerformance
)?.grade === grade
)),
`Expected persisted sortie review grades to match evaluator boundaries: ${JSON.stringify(sortieReviewGradeBoundaries)}`
);
storage.clear();
storage.set(
@@ -413,6 +457,14 @@ try {
formationAssignments: { [performanceUnit.id]: 'front' },
unitStats: [sortieStatsFixture(performanceUnit.id, { hpBefore: performanceUnit.maxHp, maxHpBefore: performanceUnit.maxHp, damageDealt: 24 })]
},
sortieReview: {
version: 1,
score: 88,
grade: 'A',
activeBondCount: 2,
enemyCount: 4,
sourcePresetIds: ['elite', 'mobile']
},
createdAt: '2026-07-03T09:05:00.000Z'
};
resetCampaignState();
@@ -420,29 +472,51 @@ try {
performanceReport.sortiePerformance.selectedUnitIds.push('ghost-unit');
performanceReport.sortiePerformance.formationAssignments[performanceUnit.id] = 'support';
performanceReport.sortiePerformance.unitStats[0].damageDealt = 999;
performanceReport.sortieReview.score = 1;
performanceReport.sortieReview.grade = 'D';
performanceReport.sortieReview.sourcePresetIds.push('siege');
const clonedPerformanceSettlement = getCampaignState();
const clonedReportPerformance = clonedPerformanceSettlement.firstBattleReport?.sortiePerformance;
const clonedHistoryPerformance = clonedPerformanceSettlement.battleHistory[firstScenario.id]?.sortiePerformance;
const clonedReportReview = clonedPerformanceSettlement.firstBattleReport?.sortieReview;
const clonedHistoryReview = clonedPerformanceSettlement.battleHistory[firstScenario.id]?.sortieReview;
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)}`
clonedHistoryPerformance.unitStats[0].damageDealt === 24 &&
clonedReportReview?.score === 88 &&
clonedReportReview.grade === 'A' &&
JSON.stringify(clonedReportReview.sourcePresetIds) === JSON.stringify(['elite', 'mobile']) &&
clonedHistoryReview?.score === 88 &&
clonedHistoryReview.grade === 'A' &&
JSON.stringify(clonedHistoryReview.sourcePresetIds) === JSON.stringify(['elite', 'mobile']),
`Expected battle report settlement to deep-clone nested sortie performance and review input: ${JSON.stringify(clonedPerformanceSettlement)}`
);
clonedReportPerformance.selectedUnitIds.push('detached-unit');
clonedReportPerformance.formationAssignments[performanceUnit.id] = 'reserve';
clonedReportPerformance.unitStats[0].damageDealt = 777;
clonedReportReview.score = 2;
clonedReportReview.grade = 'D';
clonedReportReview.sourcePresetIds.push('siege');
const unmutatedPerformanceSettlement = getCampaignState();
assert(
clonedHistoryPerformance.unitStats[0].damageDealt === 24 &&
clonedHistoryReview.score === 88 &&
clonedHistoryReview.grade === 'A' &&
JSON.stringify(clonedHistoryReview.sourcePresetIds) === JSON.stringify(['elite', 'mobile']) &&
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)}`
unmutatedPerformanceSettlement.battleHistory[firstScenario.id]?.sortiePerformance?.unitStats[0].damageDealt === 24 &&
unmutatedPerformanceSettlement.firstBattleReport?.sortieReview?.score === 88 &&
unmutatedPerformanceSettlement.firstBattleReport?.sortieReview?.grade === 'A' &&
JSON.stringify(unmutatedPerformanceSettlement.firstBattleReport?.sortieReview?.sourcePresetIds) === JSON.stringify(['elite', 'mobile']) &&
unmutatedPerformanceSettlement.battleHistory[firstScenario.id]?.sortieReview?.score === 88 &&
JSON.stringify(unmutatedPerformanceSettlement.battleHistory[firstScenario.id]?.sortieReview?.sourcePresetIds) === JSON.stringify(['elite', 'mobile']),
`Expected returned report, history, and persisted sortie performance/review snapshots to remain deeply detached: ${JSON.stringify(unmutatedPerformanceSettlement)}`
);
resetCampaignState();
const reserveDrillTemplate = campaignRecruitUnits.find((unit) => unit.id === 'jian-yong');
@@ -501,8 +575,10 @@ try {
);
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)}`
repeatedSettlement.battleHistory[firstScenario.id]?.sortiePerformance === undefined &&
repeatedSettlement.firstBattleReport?.sortieReview === undefined &&
repeatedSettlement.battleHistory[firstScenario.id]?.sortieReview === undefined,
`Expected legacy reports without sortie performance/review to remain compatible in both latest report and history: ${JSON.stringify(repeatedSettlement)}`
);
assert(
repeatedSettlement.firstBattleReport?.campaignRewards?.unlocks[0]?.battleId === 'second-battle-yellow-turban-pursuit' &&
@@ -1132,6 +1208,15 @@ try {
],
sortieItemAssignments: { 'liu-bei': { bean: 2 } }
},
sortieReview: {
version: 1,
score: '88.9',
grade: 'D',
activeBondCount: '999',
enemyCount: '1000',
sourcePresetIds: ['siege', 'elite', 'siege', 'ghost', 'mobile', 7],
itemAssignments: { 'liu-bei': { bean: 2 } }
},
itemRewards: ['Bean', 'Bean', 'Bean -2', 'Bean +0', 10, 'x'.repeat(97)],
campaignRewards: {
supplies: [' Bean ', 'Bean', 'Bean -2', 'Bean +0', 20, 'x'.repeat(97)],
@@ -1196,6 +1281,7 @@ try {
`Expected malformed report fields to be normalized without discarding the report: ${JSON.stringify(malformedReport)}`
);
const malformedReportPerformance = malformedReport.firstBattleReport?.sortiePerformance;
const malformedReportReview = malformedReport.firstBattleReport?.sortieReview;
assert(
JSON.stringify(malformedReportPerformance?.selectedUnitIds) === JSON.stringify(['liu-bei']) &&
JSON.stringify(malformedReportPerformance?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front' }) &&
@@ -1211,6 +1297,10 @@ try {
!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)}`
);
assert(
malformedReportReview === undefined,
`Expected report reviews to be discarded when roster recovery removes part of their recorded sortie: ${JSON.stringify(malformedReportReview)}`
);
storage.clear();
storage.set(
@@ -1345,6 +1435,15 @@ try {
],
itemAssignments: { 'liu-bei': { bean: 2 } }
},
sortieReview: {
version: 1,
score: '101.2',
grade: 'D',
activeBondCount: '999',
enemyCount: '-4',
sourcePresetIds: ['mobile', 'elite', 'mobile', 'ghost'],
sortieItemAssignments: { 'liu-bei': { bean: 2 } }
},
reserveTraining: [
{
unitId: 'guan-yu',
@@ -1445,6 +1544,7 @@ try {
`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;
const malformedHistoryReview = malformedHistory.battleHistory['first-battle-zhuo-commandery']?.sortieReview;
assert(
JSON.stringify(malformedHistoryPerformance?.selectedUnitIds) === JSON.stringify(['liu-bei']) &&
JSON.stringify(malformedHistoryPerformance?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front' }) &&
@@ -1460,6 +1560,10 @@ try {
!Object.prototype.hasOwnProperty.call(malformedHistoryPerformance, 'itemAssignments'),
`Expected malformed historical sortie performance to normalize and filter roster references without persisting supplies: ${JSON.stringify(malformedHistoryPerformance)}`
);
assert(
malformedHistoryReview === undefined,
`Expected historical reviews to be discarded when roster recovery removes part of their recorded sortie: ${JSON.stringify(malformedHistoryReview)}`
);
storage.clear();
storage.set(campaignStorageKey, JSON.stringify({ version: 99, step: 'sixty-sixth-camp' }));
@@ -1544,7 +1648,7 @@ try {
`Expected explicitly loaded corrupted slot timestamp to be normalized: ${JSON.stringify(corruptedSlot)}`
);
console.log('Verified campaign save normalization, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-growth/bond-roster/report-reference/sortie-roster/sortie-preset/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.');
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/sortie-review/latest-history/detail recovery, sortie performance/review legacy/deep-clone safety, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.');
} finally {
await server.close();
}

View File

@@ -132,6 +132,28 @@ try {
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 expectedFirstSortieReview = {
version: 1,
score: firstResultState.sortieEvaluation.score,
grade: firstResultState.sortieEvaluation.grade,
activeBondCount: firstResultState.sortieEvaluation.activeBondCount,
enemyCount: firstResultState.units.filter((unit) => unit.faction === 'enemy').length,
sourcePresetIds: []
};
assert(
sameJsonValue(firstResultSave.current?.firstBattleReport?.sortieReview, expectedFirstSortieReview) &&
sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortieReview, expectedFirstSortieReview) &&
sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, expectedFirstSortieReview) &&
sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, expectedFirstSortieReview) &&
firstResultSave.current.firstBattleReport.sortieReview.sourcePresetIds.length === 0,
`Expected the first persisted sortie review to match the live grade, score, enemies, bonds, and empty preset attribution everywhere: ${JSON.stringify({
expected: expectedFirstSortieReview,
currentReport: firstResultSave.current?.firstBattleReport?.sortieReview,
slotReport: firstResultSave.slot1?.firstBattleReport?.sortieReview,
currentHistory: firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview,
slotHistory: firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview
})}`
);
const firstEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle');
await page.mouse.click(firstEvaluationTogglePoint.x, firstEvaluationTogglePoint.y);
@@ -218,6 +240,7 @@ try {
);
const firstCampEvaluation = firstCampProbe.state?.reportFormationEvaluation;
const firstCampHistory = firstCampProbe.state?.reportFormationHistory;
const firstCampEvaluationSaveBefore = await readCampaignSave(page);
assert(
firstCampEvaluation?.available === true &&
@@ -229,6 +252,24 @@ try {
firstCampEvaluation.toggleButtonBounds,
`Expected the first camp report to expose the persisted battle formation evaluation: ${JSON.stringify(firstCampEvaluation)}`
);
assert(
firstCampHistory?.available === true &&
firstCampHistory.open === false &&
firstCampHistory.recordCount === 1 &&
firstCampHistory.page === 0 &&
firstCampHistory.pageCount === 1 &&
firstCampHistory.records?.length === 1 &&
firstCampHistory.records[0].battleId === 'first-battle-zhuo-commandery' &&
firstCampHistory.records[0].grade === expectedFirstSortieReview.grade &&
firstCampHistory.records[0].score === expectedFirstSortieReview.score &&
sameJsonValue(firstCampHistory.records[0].sourcePresetIds, expectedFirstSortieReview.sourcePresetIds) &&
firstCampHistory.selectedBattleId === 'first-battle-zhuo-commandery' &&
firstCampHistory.best?.battleId === 'first-battle-zhuo-commandery' &&
firstCampHistory.loadableBest?.battleId === 'first-battle-zhuo-commandery' &&
firstCampHistory.loadUndoAvailable === false &&
firstCampHistory.toggleButtonBounds,
`Expected the first camp report to expose one closed, persisted formation-history record and its best loadable sortie: ${JSON.stringify(firstCampHistory)}`
);
const firstCampEvaluationTogglePoint = await readCampReportFormationEvaluationControlPoint(page, 'toggle');
await page.mouse.click(firstCampEvaluationTogglePoint.x, firstCampEvaluationTogglePoint.y);
await page.waitForFunction(
@@ -267,6 +308,338 @@ try {
{ timeout: 30000 }
);
const firstCampHistoryMutationFixture = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const campaign = scene?.campaign;
const snapshot = campaign?.battleHistory?.['first-battle-zhuo-commandery']?.sortiePerformance;
const targetId = snapshot?.selectedUnitIds?.find((unitId) => scene?.selectedSortieUnitIds?.includes(unitId));
const historicalRole = targetId ? snapshot?.formationAssignments?.[targetId] : undefined;
const divergentRole = ['front', 'flank', 'support', 'reserve'].find((role) => role !== historicalRole);
if (
!scene ||
!campaign ||
!snapshot?.selectedUnitIds?.length ||
!targetId ||
!historicalRole ||
!divergentRole ||
typeof scene.persistSortieSelection !== 'function' ||
typeof scene.render !== 'function'
) {
return {
ready: false,
targetId: targetId ?? null,
historicalRole: historicalRole ?? null,
divergentRole: divergentRole ?? null
};
}
const before = {
selectedUnitIds: [...scene.selectedSortieUnitIds],
formationAssignments: { ...scene.sortieFormationAssignments },
itemAssignments: structuredClone(scene.sortieItemAssignments ?? {}),
focusedUnitId: scene.sortieFocusedUnitId,
planFeedback: scene.sortiePlanFeedback,
rosterScroll: scene.sortieRosterScroll
};
scene.sortieFormationAssignments = {
...scene.sortieFormationAssignments,
[targetId]: divergentRole
};
scene.persistSortieSelection();
scene.render();
return {
ready: true,
targetId,
historicalRole,
divergentRole,
before,
divergent: {
selectedUnitIds: [...scene.selectedSortieUnitIds],
formationAssignments: { ...scene.sortieFormationAssignments },
itemAssignments: structuredClone(scene.sortieItemAssignments ?? {})
}
};
});
assert(
firstCampHistoryMutationFixture.ready === true &&
firstCampHistoryMutationFixture.historicalRole !== firstCampHistoryMutationFixture.divergentRole &&
firstCampHistoryMutationFixture.divergent?.formationAssignments?.[firstCampHistoryMutationFixture.targetId] ===
firstCampHistoryMutationFixture.divergentRole,
`Expected a deterministic current-formation mismatch before exercising best-history loading: ${JSON.stringify(firstCampHistoryMutationFixture)}`
);
const firstCampHistorySaveBeforeOpen = await readCampaignSave(page);
assert(
sameJsonValue(
firstCampHistorySaveBeforeOpen.current?.selectedSortieUnitIds,
firstCampHistoryMutationFixture.divergent.selectedUnitIds
) &&
sameJsonValue(
firstCampHistorySaveBeforeOpen.current?.sortieFormationAssignments,
firstCampHistoryMutationFixture.divergent.formationAssignments
) &&
sameJsonValue(
firstCampHistorySaveBeforeOpen.current?.sortieItemAssignments,
firstCampHistoryMutationFixture.divergent.itemAssignments
) &&
sameJsonValue(
firstCampHistorySaveBeforeOpen.slot1?.selectedSortieUnitIds,
firstCampHistoryMutationFixture.divergent.selectedUnitIds
) &&
sameJsonValue(
firstCampHistorySaveBeforeOpen.slot1?.sortieFormationAssignments,
firstCampHistoryMutationFixture.divergent.formationAssignments
) &&
sameJsonValue(
firstCampHistorySaveBeforeOpen.slot1?.sortieItemAssignments,
firstCampHistoryMutationFixture.divergent.itemAssignments
),
`Expected the deliberate history-load mismatch to synchronize across current and slot-1 saves: ${JSON.stringify(firstCampHistorySaveBeforeOpen)}`
);
const firstCampHistoryTogglePoint = await readCampReportFormationHistoryControlPoint(page, 'toggle');
await page.mouse.click(firstCampHistoryTogglePoint.x, firstCampHistoryTogglePoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.open === true,
undefined,
{ timeout: 30000 }
);
const firstCampHistoryOpenProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
return {
history: window.__HEROS_DEBUG__?.camp()?.reportFormationHistory,
texts: (scene?.reportFormationHistoryObjects ?? [])
.filter((object) => object?.type === 'Text')
.map((object) => object.text)
};
});
const firstCampHistorySaveOpen = await readCampaignSave(page);
const firstCampHistoryLoadProjection = firstCampHistoryOpenProbe.history?.loadableBest;
assert(
firstCampHistoryOpenProbe.history?.open === true &&
firstCampHistoryOpenProbe.history.recordCount === 1 &&
firstCampHistoryOpenProbe.history.comparison?.matchesCurrent === false &&
firstCampHistoryOpenProbe.history.closeButtonBounds &&
firstCampHistoryOpenProbe.history.bestButtonBounds &&
firstCampHistoryOpenProbe.history.loadBestButtonBounds &&
firstCampHistoryOpenProbe.history.undoButtonBounds === null &&
sameJsonValue(firstCampHistoryLoadProjection?.projectedSelectedUnitIds, firstResultPerformance.selectedUnitIds) &&
firstResultPerformance.selectedUnitIds.every(
(unitId) => firstCampHistoryLoadProjection?.projectedFormationAssignments?.[unitId] === firstResultPerformance.formationAssignments[unitId]
) &&
sameJsonValue(
firstCampHistoryLoadProjection?.projectedItemAssignments,
firstCampHistoryMutationFixture.divergent.itemAssignments
) &&
firstCampHistoryOpenProbe.texts.includes('편성 전적표') &&
firstCampHistoryOpenProbe.texts.includes('최고 편성') &&
firstCampHistoryOpenProbe.texts.includes('현재 출전안 vs 최고 편성') &&
sameJsonValue(firstCampHistorySaveOpen, firstCampHistorySaveBeforeOpen),
`Expected opening the first camp formation history to remain non-destructive and expose best/load controls: ${JSON.stringify(firstCampHistoryOpenProbe)}`
);
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-formation-history.png`, fullPage: true });
await assertCanvasPainted(page, 'first camp formation history');
const firstCampHistoryBestPoint = await readCampReportFormationHistoryControlPoint(page, 'best');
await page.mouse.click(firstCampHistoryBestPoint.x, firstCampHistoryBestPoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.selectedBattleId === 'first-battle-zhuo-commandery',
undefined,
{ timeout: 30000 }
);
const firstCampHistorySaveAfterBest = await readCampaignSave(page);
assert(
sameJsonValue(firstCampHistorySaveAfterBest, firstCampHistorySaveBeforeOpen),
`Expected selecting the best history card to leave campaign saves untouched: ${JSON.stringify(firstCampHistorySaveAfterBest)}`
);
const firstCampHistoryLoadBestPoint = await readCampReportFormationHistoryControlPoint(page, 'loadBest');
await page.mouse.click(firstCampHistoryLoadBestPoint.x, firstCampHistoryLoadBestPoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.loadUndoAvailable === true,
undefined,
{ timeout: 30000 }
);
const firstCampHistoryAppliedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const firstCampHistoryAppliedSave = await readCampaignSave(page);
assert(
sameJsonValue(firstCampHistoryAppliedState?.selectedSortieUnitIds, firstCampHistoryLoadProjection.projectedSelectedUnitIds) &&
sameJsonValue(
firstCampHistoryAppliedState?.sortieFormationAssignments,
firstCampHistoryLoadProjection.projectedFormationAssignments
) &&
sameJsonValue(
firstCampHistoryAppliedState?.sortieItemAssignments,
firstCampHistoryLoadProjection.projectedItemAssignments
) &&
firstCampHistoryAppliedState?.reportFormationHistory?.comparison?.matchesCurrent === true &&
firstCampHistoryAppliedState?.reportFormationHistory?.loadUndoAvailable === true &&
firstCampHistoryAppliedState?.reportFormationHistory?.undoButtonBounds &&
firstCampHistoryAppliedState?.reportFormationHistory?.feedback?.includes('최고 편성 적용') &&
sameJsonValue(
firstCampHistoryAppliedSave.current?.selectedSortieUnitIds,
firstCampHistoryLoadProjection.projectedSelectedUnitIds
) &&
sameJsonValue(
firstCampHistoryAppliedSave.slot1?.selectedSortieUnitIds,
firstCampHistoryLoadProjection.projectedSelectedUnitIds
) &&
sameJsonValue(
firstCampHistoryAppliedSave.current?.sortieFormationAssignments,
firstCampHistoryAppliedState.sortieFormationAssignments
) &&
sameJsonValue(
firstCampHistoryAppliedSave.slot1?.sortieFormationAssignments,
firstCampHistoryAppliedState.sortieFormationAssignments
) &&
sameJsonValue(
firstCampHistoryAppliedSave.current?.sortieItemAssignments,
firstCampHistoryLoadProjection.projectedItemAssignments
) &&
sameJsonValue(
firstCampHistoryAppliedSave.slot1?.sortieItemAssignments,
firstCampHistoryLoadProjection.projectedItemAssignments
) &&
sameJsonValue(firstCampHistoryAppliedSave.current?.firstBattleReport?.sortieReview, expectedFirstSortieReview) &&
sameJsonValue(
firstCampHistoryAppliedSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview,
expectedFirstSortieReview
) &&
sameJsonValue(firstCampHistoryAppliedSave.slot1?.firstBattleReport?.sortieReview, expectedFirstSortieReview) &&
sameJsonValue(
firstCampHistoryAppliedSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview,
expectedFirstSortieReview
),
`Expected one best-history click to apply persisted names and roles, preserve common supplies, and keep the stored review immutable: ${JSON.stringify({
state: firstCampHistoryAppliedState,
save: firstCampHistoryAppliedSave
})}`
);
const firstCampHistoryUndoPoint = await readCampReportFormationHistoryControlPoint(page, 'undo');
await page.mouse.click(firstCampHistoryUndoPoint.x, firstCampHistoryUndoPoint.y);
await page.waitForFunction(
(fixture) => {
const state = window.__HEROS_DEBUG__?.camp();
return state?.reportFormationHistory?.loadUndoAvailable === false &&
JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(fixture.divergent.selectedUnitIds) &&
JSON.stringify(state?.sortieFormationAssignments) === JSON.stringify(fixture.divergent.formationAssignments) &&
JSON.stringify(state?.sortieItemAssignments) === JSON.stringify(fixture.divergent.itemAssignments);
},
firstCampHistoryMutationFixture,
{ timeout: 30000 }
);
const firstCampHistoryUndoneState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const firstCampHistoryUndoneSave = await readCampaignSave(page);
assert(
firstCampHistoryUndoneState?.reportFormationHistory?.open === true &&
firstCampHistoryUndoneState.reportFormationHistory.loadUndoAvailable === false &&
firstCampHistoryUndoneState.reportFormationHistory.undoButtonBounds === null &&
firstCampHistoryUndoneState.reportFormationHistory.comparison?.matchesCurrent === false &&
firstCampHistoryUndoneState.reportFormationHistory.feedback?.includes('되돌리고') &&
sameJsonValue(
firstCampHistoryUndoneSave.current?.selectedSortieUnitIds,
firstCampHistoryMutationFixture.divergent.selectedUnitIds
) &&
sameJsonValue(
firstCampHistoryUndoneSave.current?.sortieFormationAssignments,
firstCampHistoryMutationFixture.divergent.formationAssignments
) &&
sameJsonValue(
firstCampHistoryUndoneSave.current?.sortieItemAssignments,
firstCampHistoryMutationFixture.divergent.itemAssignments
) &&
sameJsonValue(
firstCampHistoryUndoneSave.slot1?.selectedSortieUnitIds,
firstCampHistoryMutationFixture.divergent.selectedUnitIds
) &&
sameJsonValue(
firstCampHistoryUndoneSave.slot1?.sortieFormationAssignments,
firstCampHistoryMutationFixture.divergent.formationAssignments
) &&
sameJsonValue(
firstCampHistoryUndoneSave.slot1?.sortieItemAssignments,
firstCampHistoryMutationFixture.divergent.itemAssignments
) &&
sameJsonValue(firstCampHistoryUndoneSave.current?.firstBattleReport?.sortieReview, expectedFirstSortieReview) &&
sameJsonValue(
firstCampHistoryUndoneSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview,
expectedFirstSortieReview
),
`Expected the real history undo control to restore the exact prior names, roles, and supplies without touching the battle review: ${JSON.stringify({
state: firstCampHistoryUndoneState,
save: firstCampHistoryUndoneSave
})}`
);
const firstCampHistoryClosePoint = await readCampReportFormationHistoryControlPoint(page, 'close');
await page.mouse.click(firstCampHistoryClosePoint.x, firstCampHistoryClosePoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.open === false,
undefined,
{ timeout: 30000 }
);
const firstCampHistoryClosedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert(
firstCampHistoryClosedState?.reportFormationHistory?.recordCount === 1 &&
firstCampHistoryClosedState.reportFormationHistory.open === false &&
firstCampHistoryClosedState.reportFormationHistory.closeButtonBounds === null &&
firstCampHistoryClosedState.reportFormationHistory.toggleButtonBounds,
`Expected the real history close control to return to the first camp report: ${JSON.stringify(firstCampHistoryClosedState?.reportFormationHistory)}`
);
await page.evaluate((before) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (!scene || typeof scene.persistSortieSelection !== 'function' || typeof scene.render !== 'function') {
throw new Error('Expected CampScene runtime methods while restoring the first-camp history fixture.');
}
scene.selectedSortieUnitIds = [...before.selectedUnitIds];
scene.sortieFormationAssignments = { ...before.formationAssignments };
scene.sortieItemAssignments = structuredClone(before.itemAssignments);
scene.sortieFocusedUnitId = before.focusedUnitId;
scene.sortiePlanFeedback = before.planFeedback;
scene.sortieRosterScroll = before.rosterScroll;
scene.persistSortieSelection();
scene.render();
}, firstCampHistoryMutationFixture.before);
await page.waitForFunction(
(before) => {
const state = window.__HEROS_DEBUG__?.camp();
return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(before.selectedUnitIds) &&
JSON.stringify(state?.sortieFormationAssignments) === JSON.stringify(before.formationAssignments) &&
JSON.stringify(state?.sortieItemAssignments) === JSON.stringify(before.itemAssignments);
},
firstCampHistoryMutationFixture.before,
{ timeout: 30000 }
);
const firstCampHistoryRestoredSave = await readCampaignSave(page);
assert(
sameJsonValue(
firstCampHistoryRestoredSave.current?.selectedSortieUnitIds,
firstCampHistoryMutationFixture.before.selectedUnitIds
) &&
sameJsonValue(
firstCampHistoryRestoredSave.current?.sortieFormationAssignments,
firstCampHistoryMutationFixture.before.formationAssignments
) &&
sameJsonValue(
firstCampHistoryRestoredSave.current?.sortieItemAssignments,
firstCampHistoryMutationFixture.before.itemAssignments
) &&
sameJsonValue(
firstCampHistoryRestoredSave.slot1?.selectedSortieUnitIds,
firstCampHistoryMutationFixture.before.selectedUnitIds
) &&
sameJsonValue(
firstCampHistoryRestoredSave.slot1?.sortieFormationAssignments,
firstCampHistoryMutationFixture.before.formationAssignments
) &&
sameJsonValue(
firstCampHistoryRestoredSave.slot1?.sortieItemAssignments,
firstCampHistoryMutationFixture.before.itemAssignments
),
`Expected the RC-only mismatch fixture to restore the original first-camp sortie before continuing: ${JSON.stringify(firstCampHistoryRestoredSave)}`
);
const firstCampSupplyMutationFixture = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const state = window.__HEROS_DEBUG__?.camp();
@@ -1396,6 +1769,9 @@ try {
const midResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const midResultSaveBeforeUpdate = await readCampaignSave(page);
const midResultPerformance = midResultState?.sortieEvaluation?.snapshot;
const midResultSortieReviewBeforePresetUpdate = structuredClone(
midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortieReview
);
const midContributorPerformance = midResultPerformance?.unitStats?.find((stats) => stats.unitId === midResultFixture.contributorId);
const midBattleRoleById = Object.fromEntries(
(midNextBattleProbe?.deployedAllyPositions ?? []).map((unit) => [unit.id, unit.formationRole])
@@ -1438,6 +1814,20 @@ try {
mobile: midResultSaveBeforeUpdate.current?.sortieFormationPresets?.mobile
})}`
);
assert(
midResultSortieReviewBeforePresetUpdate?.version === 1 &&
midResultSortieReviewBeforePresetUpdate.score === midResultState?.sortieEvaluation?.score &&
midResultSortieReviewBeforePresetUpdate.grade === midResultState?.sortieEvaluation?.grade &&
midResultSortieReviewBeforePresetUpdate.activeBondCount === midResultState?.sortieEvaluation?.activeBondCount &&
midResultSortieReviewBeforePresetUpdate.enemyCount === midNextBattleProbe.units.filter((unit) => unit.faction === 'enemy').length &&
sameJsonValue(midResultSaveBeforeUpdate.current?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
sameJsonValue(midResultSaveBeforeUpdate.slot1?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
sameJsonValue(midResultSaveBeforeUpdate.slot1?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate),
`Expected the mid-campaign result to persist one canonical sortie review before any result-screen preset update: ${JSON.stringify({
review: midResultSortieReviewBeforePresetUpdate,
evaluation: midResultState?.sortieEvaluation
})}`
);
const midEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle');
await page.mouse.click(midEvaluationTogglePoint.x, midEvaluationTogglePoint.y);
@@ -1515,6 +1905,10 @@ try {
sameJsonValue(updatedMobileResultSave.slot1?.sortieItemAssignments, midResultSaveBeforeUpdate.slot1?.sortieItemAssignments) &&
sameJsonValue(updatedMobileResultSave.current?.firstBattleReport?.sortiePerformance, midResultPerformance) &&
sameJsonValue(updatedMobileResultSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) &&
sameJsonValue(updatedMobileResultSave.current?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
sameJsonValue(updatedMobileResultSave.slot1?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
sameJsonValue(updatedMobileResultSave.current?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
sameJsonValue(updatedMobileResultSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
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({
@@ -1543,6 +1937,7 @@ try {
const midCampReviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const midCampReviewSaveBefore = await readCampaignSave(page);
const midCampFormationEvaluation = midCampReviewState?.reportFormationEvaluation;
const midCampMobileCard = midCampFormationEvaluation?.presetCards?.find((card) => card.id === 'mobile');
const midCampSiegeCard = midCampFormationEvaluation?.presetCards?.find((card) => card.id === 'siege');
assert(
midCampFormationEvaluation?.available === true &&
@@ -1551,8 +1946,9 @@ try {
midCampFormationEvaluation.grade === updatedMobileResultState?.sortieEvaluation?.grade &&
midCampFormationEvaluation.score === updatedMobileResultState?.sortieEvaluation?.score &&
sameJsonValue(midCampFormationEvaluation.snapshot, midResultPerformance) &&
midCampFormationEvaluation.sourcePresetIds?.includes('mobile') === true &&
midCampFormationEvaluation.sourcePresetIds?.includes('siege') === false &&
sameJsonValue(midCampFormationEvaluation.sourcePresetIds, midResultSortieReviewBeforePresetUpdate.sourcePresetIds) &&
midCampMobileCard?.saved === true &&
midCampMobileCard.matching === true &&
midCampSiegeCard?.saved === true &&
midCampSiegeCard.matching === false &&
midCampFormationEvaluation.toggleButtonBounds &&
@@ -1605,7 +2001,10 @@ try {
const pendingCampSiegeUpdateState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const pendingCampSiegeUpdateSave = await readCampaignSave(page);
assert(
pendingCampSiegeUpdateState?.reportFormationEvaluation?.sourcePresetIds?.includes('siege') === false &&
sameJsonValue(
pendingCampSiegeUpdateState?.reportFormationEvaluation?.sourcePresetIds,
midResultSortieReviewBeforePresetUpdate.sourcePresetIds
) &&
pendingCampSiegeUpdateState?.reportFormationEvaluation?.presetCards?.find((card) => card.id === 'siege')?.matching === false &&
sameJsonValue(pendingCampSiegeUpdateSave, midCampReviewSaveBefore),
`Expected the first camp siege update click to request confirmation without mutating either save: ${JSON.stringify({
@@ -1620,7 +2019,7 @@ try {
const evaluation = window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation;
return evaluation?.open === true &&
evaluation?.overwriteConfirmId === null &&
evaluation?.sourcePresetIds?.includes('siege') &&
evaluation?.presetCards?.find((card) => card.id === 'siege')?.matching === true &&
evaluation?.feedback?.includes('갱신 완료');
}, undefined, { timeout: 30000 });
const updatedCampSiegeState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
@@ -1645,6 +2044,10 @@ try {
sameJsonValue(updatedCampSiegeSave.slot1?.sortieItemAssignments, midCampReviewSaveBefore.slot1?.sortieItemAssignments) &&
sameJsonValue(updatedCampSiegeSave.current?.firstBattleReport?.sortiePerformance, midResultPerformance) &&
sameJsonValue(updatedCampSiegeSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) &&
sameJsonValue(updatedCampSiegeSave.current?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
sameJsonValue(updatedCampSiegeSave.slot1?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
sameJsonValue(updatedCampSiegeSave.current?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
sameJsonValue(updatedCampSiegeSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
sameJsonValue(updatedCampSiegeSave.current?.sortieFormationPresets?.mobile, midCampReviewSaveBefore.current?.sortieFormationPresets?.mobile) &&
sameJsonValue(updatedCampSiegeSave.current?.sortieFormationPresets?.elite, midCampReviewSaveBefore.current?.sortieFormationPresets?.elite) &&
JSON.stringify(updatedCampSiegeState?.selectedSortieUnitIds) === JSON.stringify(midCampReviewState?.selectedSortieUnitIds) &&
@@ -2078,6 +2481,50 @@ async function readCampReportFormationEvaluationControlPoint(page, control, pres
return probe;
}
async function readCampReportFormationHistoryControlPoint(page, control) {
const probe = await page.evaluate((control) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const history = window.__HEROS_DEBUG__?.camp()?.reportFormationHistory;
const canvas = document.querySelector('canvas');
const bounds = control === 'toggle'
? history?.toggleButtonBounds
: control === 'close'
? history?.closeButtonBounds
: control === 'best'
? history?.bestButtonBounds
: control === 'loadBest'
? history?.loadBestButtonBounds
: history?.undoButtonBounds;
if (!scene || !canvas || !bounds) {
return {
ready: false,
control,
historyOpen: history?.open ?? false,
loadUndoAvailable: history?.loadUndoAvailable ?? 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,
historyOpen: history?.open ?? false,
loadUndoAvailable: history?.loadUndoAvailable ?? false,
bounds,
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
};
}, control);
assert(
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
`Expected a visible Phaser camp formation-history ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}`
);
return probe;
}
async function readCampContentTextControlPoint(page, label, occurrence = 0) {
const probe = await page.evaluate(({ label, occurrence }) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
@@ -2249,6 +2696,7 @@ async function seedCampaignSave(page, options) {
createdAt: now
};
delete report.sortiePerformance;
delete report.sortieReview;
const settlement = {
battleId: report.battleId,
battleTitle: report.battleTitle,