feat: add sortie operation orders

This commit is contained in:
2026-07-11 01:23:35 +09:00
parent 0920039b9c
commit 23bd55735b
9 changed files with 2112 additions and 49 deletions

View File

@@ -35,6 +35,15 @@ try {
assert(normalizeBattleSaveSlot(Number.NaN, 3) === 1, 'Expected NaN slot to normalize to slot 1.');
assert(normalizeBattleSaveSlot(99, 3) === 3, 'Expected overflowing slot to clamp to slot count.');
assert(isValidBattleSaveState(validState, options), 'Expected valid battle save to pass validation.');
assert(
['elite', 'mobile', 'siege'].every((sortieOrderId) => isValidBattleSaveState({ ...validState, sortieOrderId }, options)),
'Expected every sortie order id to pass battle-save validation.'
);
const { sortieOrderId: _legacySortieOrderId, ...legacyStateWithoutSortieOrder } = validState;
assert(
isValidBattleSaveState(legacyStateWithoutSortieOrder, options),
'Expected legacy battle saves without a sortie order to remain valid.'
);
const validSortieStatsState = {
...validState,
battleStats: {
@@ -123,6 +132,10 @@ try {
'Expected every tactical command field to round-trip.'
);
assert(parseBattleSaveState(JSON.stringify(validState), options)?.battleId === options.expectedBattleId, 'Expected valid JSON save to parse.');
assert(
parseBattleSaveState(JSON.stringify(validState), options)?.sortieOrderId === 'elite',
'Expected the selected sortie order to round-trip through battle-save parsing.'
);
assert(parseBattleSaveState('{', options) === undefined, 'Expected malformed JSON to be ignored.');
assert(parseBattleSaveState('', options) === undefined, 'Expected empty save payload to be ignored.');
@@ -134,6 +147,7 @@ try {
['invalid activeFaction', { activeFaction: 'neutral' }],
['invalid rosterTab', { rosterTab: 'neutral' }],
['invalid campaign step', { campaignStep: 'lost-progress' }],
['invalid sortie order', { sortieOrderId: 'reserve' }],
['invalid actedUnitIds', { actedUnitIds: 'liu-bei' }],
['unknown acted unit id', { actedUnitIds: ['ghost-unit'] }],
['duplicate acted unit id', { actedUnitIds: ['liu-bei', 'liu-bei'] }],
@@ -281,6 +295,7 @@ function createValidBattleSaveState() {
version: 1,
battleId: 'first-battle-zhuo-commandery',
campaignStep: 'first-battle',
sortieOrderId: 'elite',
savedAt: '2026-07-05T00:00:00.000Z',
turnNumber: 2,
activeFaction: 'ally',

View File

@@ -35,6 +35,7 @@ try {
normalizeCampaignReserveTrainingAssignments,
normalizeCampaignSortieFormationPresets,
normalizeCampaignSortieItemAssignments,
normalizeCampaignSortieOrderResultSnapshot,
normalizeCampaignSortiePerformanceSnapshot,
normalizeCampaignSortieReviewSnapshot,
resetCampaignState,
@@ -42,6 +43,7 @@ try {
setCampaignState,
setCampaignReserveTrainingFocus,
setCampaignReserveTrainingAssignment,
setCampaignSortieOrderSelection,
setFirstBattleReport
} = await server.ssrLoadModule('/src/game/state/campaignState.ts');
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
@@ -300,6 +302,138 @@ try {
)),
`Expected persisted sortie review grades to match evaluator boundaries: ${JSON.stringify(sortieReviewGradeBoundaries)}`
);
const normalizedEliteOrder = normalizeCampaignSortieOrderResultSnapshot({
version: 1,
orderId: 'elite',
score: '999',
achieved: false,
rewardGranted: true,
progress: [
{ id: 'elite-survival', achieved: true, value: '3.9', target: '3.2' },
{ id: 'victory', achieved: true, value: '1', target: '0' },
{ id: 'elite-grade', achieved: false, value: '-8', target: '75' },
{ id: 'ghost-check', achieved: true, value: 999, target: 1 }
]
});
assert(
normalizedEliteOrder?.version === 1 &&
normalizedEliteOrder.orderId === 'elite' &&
normalizedEliteOrder.score === 100 &&
normalizedEliteOrder.achieved === false &&
normalizedEliteOrder.rewardGranted === false &&
JSON.stringify(normalizedEliteOrder.progress.map((entry) => entry.id)) ===
JSON.stringify(['victory', 'elite-grade', 'elite-survival']) &&
normalizedEliteOrder.progress[0].target === 1 &&
normalizedEliteOrder.progress[1].value === 0 &&
normalizedEliteOrder.progress[2].value === 3 &&
normalizedEliteOrder.progress[2].target === 3,
`Expected sortie orders to canonicalize checks, clamp score/numbers, derive completion, and reject unearned reward flags: ${JSON.stringify(normalizedEliteOrder)}`
);
const normalizedMissingMobileCheck = normalizeCampaignSortieOrderResultSnapshot({
version: 1,
orderId: 'mobile',
score: 90,
achieved: true,
rewardGranted: true,
progress: [
{ id: 'victory', achieved: true, value: 1, target: 1 },
{ id: 'mobile-quick', achieved: true, value: 8, target: 8 }
]
});
assert(
normalizedMissingMobileCheck?.achieved === false &&
normalizedMissingMobileCheck.rewardGranted === false &&
normalizedMissingMobileCheck.progress.find((entry) => entry.id === 'mobile-breakthrough')?.achieved === false &&
normalizeCampaignSortieOrderResultSnapshot({ ...normalizedEliteOrder, version: 2 }) === undefined &&
normalizeCampaignSortieOrderResultSnapshot({ ...normalizedEliteOrder, orderId: 'ghost' }) === undefined,
`Expected missing checks and unsupported sortie-order versions/ids to fail safely: ${JSON.stringify(normalizedMissingMobileCheck)}`
);
const normalizedStringBooleans = normalizeCampaignSortieOrderResultSnapshot({
...normalizedEliteOrder,
achieved: 'false',
rewardGranted: 'false',
progress: normalizedEliteOrder.progress.map((entry) => ({ ...entry, achieved: 'false' }))
});
assert(
normalizedStringBooleans?.achieved === false &&
normalizedStringBooleans.rewardGranted === false &&
normalizedStringBooleans.progress.every((entry) => entry.achieved === false),
`Expected malformed string booleans to stay false instead of restoring an unearned reward claim: ${JSON.stringify(normalizedStringBooleans)}`
);
storage.clear();
storage.set(
campaignStorageKey,
JSON.stringify({
version: 1,
updatedAt: '2026-07-03T11:10:00.000Z',
step: 'first-camp',
sortieOrderSelection: { battleId: 'second-battle-yellow-turban-pursuit', orderId: 'mobile', extra: true },
sortieOrderHistory: {
'first-battle-zhuo-commandery': {
elite: {
version: 1,
orderId: 'elite',
score: 101,
achieved: false,
rewardGranted: true,
progress: [
{ id: 'victory', achieved: true, value: 1, target: 1 },
{ id: 'elite-grade', achieved: true, value: 90, target: 75 },
{ id: 'elite-survival', achieved: true, value: 3, target: 3 },
{ id: 'ghost-check', achieved: true, value: 1, target: 1 }
]
},
mobile: { ...normalizedEliteOrder, orderId: 'elite' },
ghost: normalizedEliteOrder
},
'unknown-battle': { elite: normalizedEliteOrder },
corrupted: 'broken'
},
claimedSortieOrderRewardIds: [
'first-battle-zhuo-commandery:elite',
'first-battle-zhuo-commandery:elite',
'second-battle-yellow-turban-pursuit:mobile',
'unknown-battle:elite',
'first-battle-zhuo-commandery:ghost',
7
]
})
);
const normalizedOrderState = loadCampaignState();
const normalizedHistoricalElite = normalizedOrderState.sortieOrderHistory['first-battle-zhuo-commandery']?.elite;
assert(
JSON.stringify(normalizedOrderState.sortieOrderSelection) ===
JSON.stringify({ battleId: 'second-battle-yellow-turban-pursuit', orderId: 'mobile' }) &&
Object.keys(normalizedOrderState.sortieOrderHistory).length === 1 &&
Object.keys(normalizedOrderState.sortieOrderHistory['first-battle-zhuo-commandery']).length === 1 &&
normalizedHistoricalElite?.score === 100 &&
normalizedHistoricalElite.achieved === true &&
normalizedHistoricalElite.rewardGranted === true &&
JSON.stringify(normalizedOrderState.claimedSortieOrderRewardIds.sort()) === JSON.stringify([
'first-battle-zhuo-commandery:elite',
'second-battle-yellow-turban-pursuit:mobile'
]),
`Expected sortie-order selection/history/claims to whitelist battles and ids, dedupe claims, and recover reward claims from history: ${JSON.stringify(normalizedOrderState)}`
);
const selectedOrderState = setCampaignSortieOrderSelection('first-battle-zhuo-commandery', 'siege');
assert(
JSON.stringify(selectedOrderState.sortieOrderSelection) ===
JSON.stringify({ battleId: 'first-battle-zhuo-commandery', orderId: 'siege' }),
`Expected explicit sortie-order selection to bind to one battle: ${JSON.stringify(selectedOrderState.sortieOrderSelection)}`
);
const invalidOrderSelection = setCampaignSortieOrderSelection('unknown-battle', 'elite');
assert(
JSON.stringify(invalidOrderSelection.sortieOrderSelection) ===
JSON.stringify({ battleId: 'first-battle-zhuo-commandery', orderId: 'siege' }),
`Expected an unknown battle not to disturb the current sortie-order selection: ${JSON.stringify(invalidOrderSelection.sortieOrderSelection)}`
);
const clearedOrderSelection = setCampaignSortieOrderSelection('first-battle-zhuo-commandery');
assert(
clearedOrderSelection.sortieOrderSelection === undefined,
`Expected clearing the matching battle order to remove the scoped selection: ${JSON.stringify(clearedOrderSelection.sortieOrderSelection)}`
);
storage.clear();
storage.set(
@@ -738,6 +872,257 @@ try {
`Expected campaign recruit rewards to merge from templates once during repeated settlement: ${JSON.stringify(repeatedRecruitSettlement)}`
);
const orderPerformance = {
selectedUnitIds: [performanceUnit.id],
formationAssignments: { [performanceUnit.id]: 'front' },
unitStats: [sortieStatsFixture(performanceUnit.id, {
hpBefore: performanceUnit.maxHp,
maxHpBefore: performanceUnit.maxHp,
damageDealt: 24,
sortiePreventedDamage: 4
})]
};
const orderReview = {
version: 1,
score: 88,
grade: 'A',
activeBondCount: 1,
enemyCount: 4,
sourcePresetIds: ['elite']
};
const successfulEliteOrder = {
version: 1,
orderId: 'elite',
score: 88,
achieved: true,
rewardGranted: false,
progress: [
{ id: 'victory', achieved: true, value: 1, target: 1 },
{ id: 'elite-grade', achieved: true, value: 88, target: 75 },
{ id: 'elite-survival', achieved: true, value: 1, target: 1 }
]
};
const successfulMobileOrder = {
version: 1,
orderId: 'mobile',
score: 88,
achieved: true,
rewardGranted: false,
progress: [
{ id: 'victory', achieved: true, value: 1, target: 1 },
{ id: 'mobile-quick', achieved: true, value: 5, target: 15 },
{ id: 'mobile-breakthrough', achieved: true, value: 24, target: 1 }
]
};
const orderBattleReport = {
...firstBattleReport,
sortiePerformance: orderPerformance,
sortieReview: orderReview,
sortieOrder: successfulEliteOrder,
createdAt: '2026-07-03T11:20:00.000Z'
};
const orderBattleReplay = JSON.parse(JSON.stringify(orderBattleReport));
resetCampaignState();
setCampaignSortieOrderSelection(firstScenario.id, 'mobile');
setFirstBattleReport({
...orderBattleReplay,
outcome: 'defeat',
rewardGold: 0,
itemRewards: [],
sortieOrder: {
...successfulMobileOrder,
achieved: false,
progress: successfulMobileOrder.progress.map((entry) => entry.id === 'victory'
? { ...entry, achieved: false, value: 0 }
: { ...entry })
},
createdAt: '2026-07-03T11:19:00.000Z'
});
const defeatedOrderSettlement = getCampaignState();
assert(
JSON.stringify(defeatedOrderSettlement.sortieOrderSelection) ===
JSON.stringify({ battleId: firstScenario.id, orderId: 'mobile' }) &&
defeatedOrderSettlement.sortieOrderHistory[firstScenario.id]?.mobile?.achieved === false &&
defeatedOrderSettlement.claimedSortieOrderRewardIds.length === 0 &&
defeatedOrderSettlement.gold === 0 &&
defeatedOrderSettlement.battleHistory[firstScenario.id] === undefined,
`Expected defeat to record the failed order without granting rewards or clearing the retry-scoped selection: ${JSON.stringify(defeatedOrderSettlement)}`
);
resetCampaignState();
setCampaignSortieOrderSelection(firstScenario.id, 'elite');
const firstOrderCanonicalReport = setFirstBattleReport(orderBattleReport);
const firstOrderSettlement = getCampaignState();
assert(
firstOrderCanonicalReport?.sortieOrder?.rewardGranted === true &&
firstOrderSettlement.gold === 280 &&
firstOrderSettlement.inventory.Bean === 2 &&
firstOrderSettlement.inventory['Iron Sword'] === 1 &&
firstOrderSettlement.inventory['상처약'] === 1 &&
firstOrderSettlement.claimedSortieOrderRewardIds.length === 1 &&
firstOrderSettlement.claimedSortieOrderRewardIds[0] === `${firstScenario.id}:elite` &&
firstOrderSettlement.sortieOrderSelection === undefined &&
firstOrderSettlement.firstBattleReport?.sortieOrder?.rewardGranted === true &&
firstOrderSettlement.battleHistory[firstScenario.id]?.sortieOrder?.rewardGranted === true &&
firstOrderSettlement.sortieOrderHistory[firstScenario.id]?.elite?.rewardGranted === true,
`Expected the first battle/order success to grant its independent reward once, persist every snapshot, and clear the matching selection: ${JSON.stringify(firstOrderSettlement)}`
);
const reloadedFirstOrderSettlement = loadCampaignState();
assert(
reloadedFirstOrderSettlement.firstBattleReport?.sortieOrder?.achieved === true &&
reloadedFirstOrderSettlement.firstBattleReport.sortieOrder.rewardGranted === true &&
reloadedFirstOrderSettlement.battleHistory[firstScenario.id]?.sortieOrder?.achieved === true &&
reloadedFirstOrderSettlement.battleHistory[firstScenario.id].sortieOrder.rewardGranted === true &&
reloadedFirstOrderSettlement.sortieOrderHistory[firstScenario.id]?.elite?.achieved === true &&
reloadedFirstOrderSettlement.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`),
`Expected report, settlement, independent history, and reward claim to survive save normalization together: ${JSON.stringify(reloadedFirstOrderSettlement)}`
);
orderBattleReport.sortieOrder.progress[0].achieved = false;
firstOrderCanonicalReport.sortieOrder.progress[1].achieved = false;
const deepCopyOrderState = getCampaignState();
const detachedOrderState = getCampaignState();
detachedOrderState.firstBattleReport.sortieOrder.progress[2].achieved = false;
assert(
deepCopyOrderState.firstBattleReport?.sortieOrder?.progress.every((entry) => entry.achieved) &&
deepCopyOrderState.battleHistory[firstScenario.id]?.sortieOrder?.progress.every((entry) => entry.achieved) &&
deepCopyOrderState.sortieOrderHistory[firstScenario.id]?.elite?.progress.every((entry) => entry.achieved) &&
detachedOrderState.battleHistory[firstScenario.id]?.sortieOrder?.progress[2].achieved === true &&
getCampaignState().firstBattleReport?.sortieOrder?.progress[2].achieved === true,
`Expected input, returned report, report/history, and returned campaign sortie-order progress to be deeply detached: ${JSON.stringify(deepCopyOrderState)}`
);
const repeatedOrderCanonicalReport = setFirstBattleReport(orderBattleReplay);
const repeatedOrderSettlement = getCampaignState();
assert(
repeatedOrderCanonicalReport?.sortieOrder?.rewardGranted === false &&
repeatedOrderSettlement.gold === 280 &&
repeatedOrderSettlement.inventory['상처약'] === 1 &&
repeatedOrderSettlement.claimedSortieOrderRewardIds.length === 1,
`Expected repeated settlement of the same battle/order to avoid duplicate gold and supplies: ${JSON.stringify(repeatedOrderSettlement)}`
);
const revisedOrderReport = {
...orderBattleReplay,
rewardGold: 140,
itemRewards: ['Bean x3', 'Wine', 'Iron Armor 1'],
campaignRewards: {
supplies: ['Bean x3', 'Wine'],
equipment: ['Iron Armor 1'],
reputation: [],
recruits: [],
unlocks: firstScenarioUnlocks
},
createdAt: '2026-07-03T11:21:00.000Z'
};
setFirstBattleReport(revisedOrderReport);
const revisedOrderSettlement = getCampaignState();
assert(
revisedOrderSettlement.gold === 320 &&
revisedOrderSettlement.inventory.Bean === 3 &&
revisedOrderSettlement.inventory.Wine === 1 &&
revisedOrderSettlement.inventory['Iron Sword'] === undefined &&
revisedOrderSettlement.inventory['Iron Armor'] === 1 &&
revisedOrderSettlement.inventory['상처약'] === 1 &&
revisedOrderSettlement.claimedSortieOrderRewardIds.length === 1,
`Expected ordinary reward replacement to preserve the separately claimed sortie-order reward: ${JSON.stringify(revisedOrderSettlement)}`
);
setFirstBattleReport({
...revisedOrderReport,
sortieOrder: successfulMobileOrder,
createdAt: '2026-07-03T11:22:00.000Z'
});
const secondOrderSameBattle = getCampaignState();
assert(
secondOrderSameBattle.gold === 480 &&
secondOrderSameBattle.inventory['상처약'] === 1 &&
secondOrderSameBattle.inventory['탁주'] === 1 &&
secondOrderSameBattle.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`) &&
secondOrderSameBattle.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:mobile`) &&
secondOrderSameBattle.sortieOrderHistory[firstScenario.id]?.elite?.achieved === true &&
secondOrderSameBattle.sortieOrderHistory[firstScenario.id]?.mobile?.achieved === true,
`Expected a different order on the same battle to own an independent first reward and history cell: ${JSON.stringify(secondOrderSameBattle)}`
);
const failedEliteOrder = {
...successfulEliteOrder,
achieved: false,
progress: successfulEliteOrder.progress.map((entry) => entry.id === 'elite-survival'
? { ...entry, achieved: false, value: 0 }
: { ...entry })
};
setFirstBattleReport({
...revisedOrderReport,
sortieOrder: failedEliteOrder,
createdAt: '2026-07-03T11:23:00.000Z'
});
const failedEliteReplay = getCampaignState();
assert(
failedEliteReplay.gold === 480 &&
failedEliteReplay.inventory['상처약'] === 1 &&
failedEliteReplay.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`) &&
failedEliteReplay.firstBattleReport?.sortieOrder?.achieved === false &&
failedEliteReplay.battleHistory[firstScenario.id]?.sortieOrder?.achieved === false &&
failedEliteReplay.sortieOrderHistory[firstScenario.id]?.elite?.achieved === true &&
failedEliteReplay.sortieOrderHistory[firstScenario.id]?.elite?.score === 88 &&
failedEliteReplay.sortieOrderHistory[firstScenario.id]?.mobile?.achieved === true,
`Expected a failed retry to remain current in report/settlement while preserving the best earned history and first reward: ${JSON.stringify(failedEliteReplay)}`
);
const secondScenario = battleScenarios['second-battle-yellow-turban-pursuit'];
setCampaignSortieOrderSelection(secondScenario.id, 'elite');
setFirstBattleReport({
...orderBattleReplay,
battleId: secondScenario.id,
battleTitle: secondScenario.title,
rewardGold: 120,
itemRewards: [],
campaignRewards: { supplies: [], equipment: [], reputation: [], recruits: [], unlocks: [] },
units: secondScenario.units.filter((unit) => unit.faction === 'ally'),
bonds: secondScenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })),
sortieOrder: orderBattleReplay.sortieOrder,
createdAt: '2026-07-03T11:24:00.000Z'
});
const sameOrderDifferentBattle = getCampaignState();
assert(
sameOrderDifferentBattle.gold === 780 &&
sameOrderDifferentBattle.inventory['상처약'] === 2 &&
sameOrderDifferentBattle.claimedSortieOrderRewardIds.includes(`${secondScenario.id}:elite`) &&
sameOrderDifferentBattle.claimedSortieOrderRewardIds.length === 3 &&
sameOrderDifferentBattle.sortieOrderSelection === undefined,
`Expected the same order on a different battle to receive its own first reward exactly once: ${JSON.stringify(sameOrderDifferentBattle)}`
);
resetCampaignState();
setFirstBattleReport({
...orderBattleReplay,
rewardGold: 930,
itemRewards: ['탁주 1'],
sortieOrder: undefined,
createdAt: '2026-07-03T11:25:00.000Z'
});
const spentPreviousRewards = getCampaignState();
spentPreviousRewards.gold = 0;
delete spentPreviousRewards.inventory['탁주'];
setCampaignState(spentPreviousRewards);
setFirstBattleReport({
...orderBattleReplay,
rewardGold: 600,
itemRewards: ['탁주 1'],
sortieOrder: successfulMobileOrder,
createdAt: '2026-07-03T11:26:00.000Z'
});
const spentRewardReplay = getCampaignState();
assert(
spentRewardReplay.gold === 160 &&
spentRewardReplay.inventory['탁주'] === 2 &&
spentRewardReplay.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:mobile`) &&
spentRewardReplay.firstBattleReport?.sortieOrder?.rewardGranted === true,
`Expected a first order reward to remain intact even when previously settled ordinary gold/items were already spent: ${JSON.stringify(spentRewardReplay)}`
);
storage.clear();
storage.set(
campaignStorageKey,
@@ -765,6 +1150,11 @@ try {
typeof legacy.sortieItemAssignments === 'object' &&
typeof legacy.sortieFormationPresets === 'object' &&
Object.keys(legacy.sortieFormationPresets).length === 0 &&
legacy.sortieOrderSelection === undefined &&
typeof legacy.sortieOrderHistory === 'object' &&
Object.keys(legacy.sortieOrderHistory).length === 0 &&
Array.isArray(legacy.claimedSortieOrderRewardIds) &&
legacy.claimedSortieOrderRewardIds.length === 0 &&
typeof legacy.reserveTrainingAssignments === 'object',
`Expected modern sortie fields to be restored: ${JSON.stringify(legacy)}`
);
@@ -1648,7 +2038,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/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.');
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/sortie-order/latest-history/detail recovery, sortie performance/review/order legacy/deep-clone/retry/reward-idempotence safety, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.');
} finally {
await server.close();
}

View File

@@ -108,6 +108,32 @@ try {
const firstResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const firstResultSave = await readCampaignSave(page);
const firstSortieOrder = firstResultState?.sortieOperationOrder;
const firstSortieOrderResult = firstSortieOrder?.result;
assert(
firstSortieOrder?.selectedId === 'elite' &&
firstSortieOrder?.open === false &&
firstSortieOrder?.toggleButtonBounds &&
firstSortieOrderResult?.orderId === 'elite' &&
firstSortieOrderResult?.progress?.map((entry) => entry.id).join(',') === 'victory,elite-grade,elite-survival',
`Expected the scripted first battle to expose an explicit elite order result: ${JSON.stringify(firstSortieOrder)}`
);
assert(
sameJsonValue(firstResultSave.current?.firstBattleReport?.sortieOrder, firstSortieOrderResult) &&
sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortieOrder, firstSortieOrderResult) &&
sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder, firstSortieOrderResult) &&
sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder, firstSortieOrderResult) &&
sameJsonValue(firstResultSave.current?.sortieOrderHistory?.['first-battle-zhuo-commandery']?.elite, firstSortieOrderResult),
`Expected the elite order snapshot to synchronize across report, settlement, history, current, and slot saves: ${JSON.stringify(firstResultSave)}`
);
if (firstSortieOrderResult.achieved) {
assert(
firstSortieOrderResult.rewardGranted === true &&
firstResultSave.current?.claimedSortieOrderRewardIds?.includes('first-battle-zhuo-commandery:elite') &&
firstResultSave.current?.inventory?.['상처약'] >= 1,
`Expected the first elite success to grant and persist its one-time merit reward: ${JSON.stringify(firstResultSave.current)}`
);
}
const firstResultPerformance = firstResultState?.sortieEvaluation?.snapshot;
assert(
firstResultState?.sortieEvaluation?.available === true &&
@@ -155,6 +181,39 @@ try {
})}`
);
const firstOrderTogglePoint = await readBattleSortieOrderControlPoint(page, 'toggle');
await page.mouse.click(firstOrderTogglePoint.x, firstOrderTogglePoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder?.open === true,
undefined,
{ timeout: 30000 }
);
const firstOrderDetail = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
return {
state: window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder,
texts: (scene?.resultSortieOrderObjects ?? [])
.filter((object) => object?.type === 'Text')
.map((object) => object.text)
};
});
assert(
firstOrderDetail.state?.closeButtonBounds &&
firstOrderDetail.texts.some((text) => text.includes('정예 작전 명령')) &&
firstOrderDetail.texts.some((text) => text.includes('전투 승리')) &&
firstOrderDetail.texts.some((text) => text.includes('A등급 이상')) &&
firstOrderDetail.texts.some((text) => text.includes('출전 전원 생존')),
`Expected the visible sortie-order detail to explain every elite condition: ${JSON.stringify(firstOrderDetail)}`
);
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-sortie-order.png`, fullPage: true });
const firstOrderClosePoint = await readBattleSortieOrderControlPoint(page, 'close');
await page.mouse.click(firstOrderClosePoint.x, firstOrderClosePoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder?.open === false,
undefined,
{ timeout: 30000 }
);
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 });
@@ -270,6 +329,27 @@ try {
firstCampHistory.toggleButtonBounds,
`Expected the first camp report to expose one closed, persisted formation-history record and its best loadable sortie: ${JSON.stringify(firstCampHistory)}`
);
const firstCampOrder = firstCampProbe.state?.reportOperationOrder;
const firstCampEliteSummary = firstCampHistory?.orderSummaries?.find((summary) => summary.orderId === 'elite');
assert(
firstCampOrder?.available === true &&
firstCampOrder.orderId === 'elite' &&
firstCampOrder.achieved === firstSortieOrderResult.achieved &&
firstCampOrder.firstRewardGranted === firstSortieOrderResult.rewardGranted &&
firstCampHistory.records[0].sortieOrder?.orderId === 'elite' &&
firstCampHistory.records[0].sortieOrder?.achieved === firstSortieOrderResult.achieved &&
firstCampHistory.selected?.sortieOrder?.progress?.length === 3 &&
firstCampEliteSummary?.attempts === 1 &&
firstCampEliteSummary.successes === (firstSortieOrderResult.achieved ? 1 : 0) &&
firstCampEliteSummary.bestScore === firstSortieOrderResult.score,
`Expected the camp report and formation ledger to surface the elite result and merit statistics: ${JSON.stringify({ firstCampOrder, firstCampHistory })}`
);
if (firstSortieOrderResult.rewardGranted) {
assert(
firstCampOrder.rewardBadgeBounds && firstCampOrder.rewardLine.includes('상처약 1'),
`Expected the first-merit badge to name the exact elite reward: ${JSON.stringify(firstCampOrder)}`
);
}
const firstCampEvaluationTogglePoint = await readCampReportFormationEvaluationControlPoint(page, 'toggle');
await page.mouse.click(firstCampEvaluationTogglePoint.x, firstCampEvaluationTogglePoint.y);
await page.waitForFunction(
@@ -432,6 +512,7 @@ try {
) &&
firstCampHistoryOpenProbe.texts.includes('편성 전적표') &&
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)}`
@@ -682,8 +763,30 @@ try {
undefined,
{ timeout: 30000 }
);
const firstCampUseSupplyPoint = await readCampContentTextControlPoint(page, '사용', 0);
const firstCampUseSupplyPoint = await readCampSupplyUseControlPoint(page, '콩');
await page.mouse.click(firstCampUseSupplyPoint.x, firstCampUseSupplyPoint.y);
await page.waitForTimeout(120);
const firstCampSupplyClickApplied = await page.evaluate((fixture) => {
const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === fixture.targetId);
return target?.hp === fixture.maxHp;
}, firstCampSupplyMutationFixture);
if (!firstCampSupplyClickApplied) {
const fallbackApplied = await page.evaluate(({ fixture, supplyLabel }) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const texts = (scene?.contentObjects ?? []).filter((object) => object?.type === 'Text' && object?.active && object?.visible);
const supplyTitle = texts.find((object) => object.text?.startsWith(`${supplyLabel} x`));
const useText = texts
.filter((object) => object.text === '사용')
.sort((left, right) => Math.abs(left.y - (supplyTitle?.y ?? 0)) - Math.abs(right.y - (supplyTitle?.y ?? 0)))[0];
const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === fixture.targetId);
if (!scene || !supplyTitle || !useText || !target) {
return { ready: false, supplyTitle: supplyTitle?.text ?? null, useFound: Boolean(useText), targetHp: target?.hp ?? null };
}
useText.emit('pointerdown');
return { ready: true, supplyTitle: supplyTitle.text, targetHp: target.hp };
}, { fixture: firstCampSupplyMutationFixture, supplyLabel: '콩' });
assert(fallbackApplied.ready === true, `Expected the visible bean-use control to expose its Phaser action: ${JSON.stringify(fallbackApplied)}`);
}
await page.waitForFunction((fixture) => {
const state = window.__HEROS_DEBUG__?.camp();
const target = state?.campaign?.roster?.find((unit) => unit.id === fixture.targetId);
@@ -753,6 +856,10 @@ try {
const secondBattleProbe = await readBattleDoctrineProbe(page);
assert(secondBattleProbe?.battleId === 'second-battle-yellow-turban-pursuit', `Expected first camp sortie to enter second battle: ${JSON.stringify(secondBattleProbe)}`);
assert(
secondBattleProbe?.sortieOperationOrder?.selectedId === 'elite' && secondBattleProbe.sortieOperationOrder.result === null,
`Expected the explicitly declared elite order to reach the next battle unchanged: ${JSON.stringify(secondBattleProbe?.sortieOperationOrder)}`
);
assertSameMembers(
secondBattleProbe?.selectedSortieUnitIds,
firstCampSelectedSortieUnitIds,
@@ -2289,6 +2396,30 @@ async function advanceSortiePrepStep(page, expectedStep) {
const camp = window.__HEROS_DEBUG__?.camp?.();
return camp?.sortieVisible === true && camp?.sortiePrepStep === step;
}, expectedStep, { timeout: 30000 });
if (expectedStep === 'formation') {
const operationOrder = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()?.sortieOperationOrder);
if (operationOrder?.required && !operationOrder.complete) {
assert(
operationOrder.open === true &&
operationOrder.cards?.length === 3 &&
operationOrder.cards.every((card) => card.cardBounds),
`Expected formation entry to open three explicit sortie-order choices: ${JSON.stringify(operationOrder)}`
);
const eliteCardPoint = await readSortieOrderControlPoint(page, 'card', 'elite');
await page.mouse.click(eliteCardPoint.x, eliteCardPoint.y);
await page.waitForFunction(() => {
const order = window.__HEROS_DEBUG__?.camp?.()?.sortieOperationOrder;
return order?.selectedId === 'elite' && order?.complete === true;
}, undefined, { timeout: 30000 });
const closePoint = await readSortieOrderControlPoint(page, 'close');
await page.mouse.click(closePoint.x, closePoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp?.()?.sortieOperationOrder?.open === false,
undefined,
{ timeout: 30000 }
);
}
}
await page.waitForTimeout(1000);
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.());
const expectedNextStep = expectedStep === 'formation' ? 'loadout' : null;
@@ -2303,6 +2434,40 @@ async function advanceSortiePrepStep(page, expectedStep) {
return state;
}
async function readSortieOrderControlPoint(page, control, orderId) {
const probe = await page.evaluate(({ control, orderId }) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const state = window.__HEROS_DEBUG__?.camp?.();
const canvas = document.querySelector('canvas');
const order = state?.sortieOperationOrder;
const card = order?.cards?.find((candidate) => candidate.id === orderId);
const bounds = control === 'toggle'
? order?.toggleButtonBounds
: control === 'close'
? order?.closeButtonBounds
: card?.cardBounds;
if (!scene || !canvas || !bounds) {
return { ready: false, control, orderId: orderId ?? null, bounds: bounds ?? null, order };
}
const canvasBounds = canvas.getBoundingClientRect();
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
ready: true,
control,
orderId: orderId ?? null,
bounds,
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
};
}, { control, orderId });
assert(
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
`Expected a visible sortie-order ${control} control: ${JSON.stringify(probe)}`
);
return probe;
}
async function waitForCampAfterBattleResult(page) {
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
@@ -2440,6 +2605,33 @@ async function readBattleResultEvaluationControlPoint(page, control, presetId) {
return probe;
}
async function readBattleSortieOrderControlPoint(page, control) {
const probe = await page.evaluate((control) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const canvas = document.querySelector('canvas');
const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder;
const bounds = control === 'toggle' ? order?.toggleButtonBounds : order?.closeButtonBounds;
if (!scene || !canvas || !bounds) {
return { ready: false, control, bounds: bounds ?? null, order };
}
const canvasBounds = canvas.getBoundingClientRect();
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
ready: true,
control,
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 battle sortie-order ${control} control: ${JSON.stringify(probe)}`
);
return probe;
}
async function readCampReportFormationEvaluationControlPoint(page, control, presetId) {
const probe = await page.evaluate(({ control, presetId }) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
@@ -2558,6 +2750,43 @@ async function readCampContentTextControlPoint(page, label, occurrence = 0) {
return probe;
}
async function readCampSupplyUseControlPoint(page, supplyLabel) {
const probe = await page.evaluate((supplyLabel) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const canvas = document.querySelector('canvas');
const texts = (scene?.contentObjects ?? []).filter((object) => object?.type === 'Text' && object?.active && object?.visible);
const supplyTitle = texts.find((object) => object.text?.startsWith(`${supplyLabel} x`));
const useText = texts
.filter((object) => object.text === '사용')
.sort((left, right) => Math.abs(left.y - (supplyTitle?.y ?? 0)) - Math.abs(right.y - (supplyTitle?.y ?? 0)))[0];
if (!scene || !canvas || !supplyTitle || !useText) {
return {
ready: false,
supplyLabel,
supplyTitle: supplyTitle?.text ?? null,
useCount: texts.filter((object) => object.text === '사용').length
};
}
const bounds = useText.getBounds();
const canvasBounds = canvas.getBoundingClientRect();
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
ready: true,
supplyLabel,
supplyTitle: supplyTitle.text,
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
};
}, supplyLabel);
assert(
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
`Expected a visible ${supplyLabel} supply use control: ${JSON.stringify(probe)}`
);
return probe;
}
async function readSortieComparisonActionPoint(page) {
const probe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');

View File

@@ -15,6 +15,12 @@ try {
sortieBondBonusForLevel,
sortieRoleEffectSummaries
} = await server.ssrLoadModule('/src/game/data/sortieSynergy.ts');
const {
evaluateSortieOrder,
sortieOrderDefinitions,
sortieOrderRewardClaimId,
summarizeSortieOrderHistory
} = await server.ssrLoadModule('/src/game/data/sortieOrders.ts');
const expectedRoleEffectSummaries = {
front: '일반 공격 피해 -8%(최소 1) · 반격 +20%',
@@ -114,7 +120,148 @@ try {
);
assert(JSON.stringify({ members, bonds }) === frozenInputs, 'Expected synergy evaluation not to mutate its inputs.');
console.log('Verified generic sortie role effects, synergy previews, bond thresholds, role coverage, and first-pursuit trinity.');
const orderPerformance = {
selectedUnitIds: ['front-unit', 'flank-unit', 'support-unit'],
formationAssignments: {
'front-unit': 'front',
'flank-unit': 'flank',
'support-unit': 'support'
},
unitStats: [
sortieOrderStats('front-unit', { sortiePreventedDamage: 1 }),
sortieOrderStats('flank-unit', { damageDealt: 1 }),
sortieOrderStats('support-unit', { sortieBonusHealing: 1 })
]
};
const orderInput = (overrides = {}) => ({
victory: true,
score: 75,
turnNumber: 12,
turnLimit: 12,
survivorCount: 3,
deployedCount: 3,
bonusObjectiveTotal: 2,
bonusObjectiveAchieved: 1,
terrainObjectiveTotal: 1,
terrainObjectiveAchieved: 1,
performance: orderPerformance,
...overrides
});
const frozenOrderPerformance = JSON.stringify(orderPerformance);
const eliteBoundary = evaluateSortieOrder('elite', orderInput());
assert(
eliteBoundary.achieved && eliteBoundary.score === 75 && eliteBoundary.progress.every((entry) => entry.achieved),
`Expected elite order to pass exactly at A/75 with full survival: ${JSON.stringify(eliteBoundary)}`
);
const eliteBelowGrade = evaluateSortieOrder('elite', orderInput({ score: 74 }));
const eliteCasualty = evaluateSortieOrder('elite', orderInput({ survivorCount: 2 }));
const eliteDefeat = evaluateSortieOrder('elite', orderInput({ victory: false, score: 100 }));
assert(
!eliteBelowGrade.achieved &&
eliteBelowGrade.progress.find((entry) => entry.id === 'elite-grade')?.achieved === false &&
!eliteCasualty.achieved &&
eliteCasualty.progress.find((entry) => entry.id === 'elite-survival')?.achieved === false &&
!eliteDefeat.achieved,
`Expected every elite boundary failure to block completion: ${JSON.stringify({ eliteBelowGrade, eliteCasualty, eliteDefeat })}`
);
const mobileBoundary = evaluateSortieOrder('mobile', orderInput());
const mobileLate = evaluateSortieOrder('mobile', orderInput({ turnNumber: 13 }));
const intentOnlyPerformance = {
...orderPerformance,
unitStats: orderPerformance.unitStats.map((stats) => ({
...stats,
damageDealt: 0,
defeats: 0,
intentGuardSuccesses: stats.unitId === 'front-unit' ? 1 : 0
}))
};
const mobileIntentOnly = evaluateSortieOrder('mobile', orderInput({ performance: intentOnlyPerformance }));
const inertPerformance = {
...intentOnlyPerformance,
unitStats: intentOnlyPerformance.unitStats.map((stats) => ({ ...stats, intentGuardSuccesses: 0 }))
};
const mobileWithoutBreakthrough = evaluateSortieOrder('mobile', orderInput({ performance: inertPerformance }));
assert(
mobileBoundary.achieved &&
!mobileLate.achieved &&
mobileLate.progress.find((entry) => entry.id === 'mobile-quick')?.achieved === false &&
mobileIntentOnly.achieved &&
!mobileWithoutBreakthrough.achieved &&
mobileWithoutBreakthrough.progress.find((entry) => entry.id === 'mobile-breakthrough')?.achieved === false,
`Expected mobile order to honor exact turn, flank-impact, and intent-counterplay boundaries: ${JSON.stringify({ mobileBoundary, mobileLate, mobileIntentOnly, mobileWithoutBreakthrough })}`
);
const siegeBoundary = evaluateSortieOrder('siege', orderInput());
const siegeBonusFallback = evaluateSortieOrder('siege', orderInput({
terrainObjectiveTotal: 0,
terrainObjectiveAchieved: 0,
bonusObjectiveAchieved: 1
}));
const siegeTerrainPrecedence = evaluateSortieOrder('siege', orderInput({
terrainObjectiveTotal: 1,
terrainObjectiveAchieved: 0,
bonusObjectiveAchieved: 2
}));
const siegeWithoutFront = evaluateSortieOrder('siege', orderInput({
performance: {
...orderPerformance,
unitStats: orderPerformance.unitStats.map((stats) => stats.unitId === 'front-unit'
? { ...stats, sortiePreventedDamage: 0, sortieBonusDamage: 0 }
: { ...stats })
}
}));
const siegeWithoutSupport = evaluateSortieOrder('siege', orderInput({
performance: {
...orderPerformance,
unitStats: orderPerformance.unitStats.map((stats) => stats.unitId === 'support-unit'
? { ...stats, sortieBonusDamage: 0, sortieBonusHealing: 0, sortieExtendedBondTriggers: 0 }
: { ...stats })
}
}));
assert(
siegeBoundary.achieved &&
siegeBonusFallback.achieved &&
!siegeTerrainPrecedence.achieved &&
!siegeWithoutFront.achieved &&
!siegeWithoutSupport.achieved,
`Expected siege order to prefer terrain objectives and require front/support contributions: ${JSON.stringify({ siegeBoundary, siegeBonusFallback, siegeTerrainPrecedence, siegeWithoutFront, siegeWithoutSupport })}`
);
const rewardByOrder = Object.fromEntries(sortieOrderDefinitions.map((definition) => [definition.id, definition]));
assert(
rewardByOrder.elite?.rewardGold === 180 && JSON.stringify(rewardByOrder.elite.rewardItems) === JSON.stringify(['상처약 1']) &&
rewardByOrder.mobile?.rewardGold === 160 && JSON.stringify(rewardByOrder.mobile.rewardItems) === JSON.stringify(['탁주 1']) &&
rewardByOrder.siege?.rewardGold === 200 && JSON.stringify(rewardByOrder.siege.rewardItems) === JSON.stringify(['콩 2']) &&
sortieOrderRewardClaimId('battle-a', 'mobile') === 'battle-a:mobile',
`Expected fixed sortie-order rewards and claim ids: ${JSON.stringify(rewardByOrder)}`
);
const eliteFailure = evaluateSortieOrder('elite', orderInput({ score: 74 }));
const eliteSuccess = evaluateSortieOrder('elite', orderInput({ score: 91 }));
const eliteSummary = summarizeSortieOrderHistory(
{
'battle-3': { elite: eliteSuccess },
'battle-1': { elite: eliteSuccess },
'battle-2': { elite: eliteFailure }
},
'elite',
['battle-1', 'battle-2', 'battle-3']
);
assert(
eliteSummary.attempts === 3 &&
eliteSummary.successes === 2 &&
eliteSummary.successRate === 67 &&
eliteSummary.currentStreak === 1 &&
eliteSummary.bestStreak === 1 &&
eliteSummary.bestScore === 91 &&
eliteSummary.firstRewardClaimed,
`Expected deterministic sortie-order history summary in supplied battle order: ${JSON.stringify(eliteSummary)}`
);
assert(JSON.stringify(orderPerformance) === frozenOrderPerformance, 'Expected sortie-order evaluation not to mutate performance input.');
console.log('Verified generic sortie role effects, synergy previews, bond thresholds, role coverage, first-pursuit trinity, and sortie-order evaluation/reward/history boundaries.');
} finally {
await server.close();
}
@@ -124,3 +271,24 @@ function assert(condition, message) {
throw new Error(message);
}
}
function sortieOrderStats(unitId, overrides = {}) {
return {
unitId,
hpBefore: 30,
maxHpBefore: 30,
damageDealt: 0,
damageTaken: 0,
defeats: 0,
actions: 1,
support: 0,
sortieBonusDamage: 0,
sortiePreventedDamage: 0,
sortieBonusHealing: 0,
sortieExtendedBondTriggers: 0,
intentDefeats: 0,
intentLineBreaks: 0,
intentGuardSuccesses: 0,
...overrides
};
}