feat: unlock tactical commands from sortie orders
This commit is contained in:
@@ -63,7 +63,8 @@ try {
|
||||
const tacticalOptions = {
|
||||
...options,
|
||||
expectedBattleId: 'second-battle-yellow-turban-pursuit',
|
||||
allowTacticalCommand: true
|
||||
allowTacticalCommand: true,
|
||||
allowLegacyTacticalCommand: true
|
||||
};
|
||||
const validTacticalBaseState = {
|
||||
...validState,
|
||||
@@ -114,10 +115,62 @@ try {
|
||||
effectLost: true
|
||||
}
|
||||
};
|
||||
const validInitiativeCommandState = {
|
||||
...validFrontCommandState,
|
||||
tacticalCommand: { ...validFrontCommandState.tacticalCommand, source: 'initiative' }
|
||||
};
|
||||
const validUnlockedOrderCommandState = {
|
||||
...validTacticalBaseState,
|
||||
battleStats: validState.battleStats,
|
||||
sortieOrderCommandUnlocked: true,
|
||||
sortieOrderCommandUnlockTurn: 2,
|
||||
tacticalCommand: {
|
||||
...validFrontCommandState.tacticalCommand,
|
||||
source: 'sortie-order'
|
||||
}
|
||||
};
|
||||
assert(isValidBattleSaveState(validFrontCommandState, tacticalOptions), 'Expected pending front tactical command to pass validation.');
|
||||
assert(isValidBattleSaveState(validInitiativeCommandState, tacticalOptions), 'Expected an explicit initiative source to pass validation.');
|
||||
assert(isValidBattleSaveState(validFlankCommandState, tacticalOptions), 'Expected resolved flank tactical command to pass validation.');
|
||||
assert(isValidBattleSaveState(validSupportCommandState, tacticalOptions), 'Expected immediate support tactical command to pass validation.');
|
||||
assert(isValidBattleSaveState(validLostFrontCommandState, tacticalOptions), 'Expected a lost tactical command to retain its result record.');
|
||||
assert(
|
||||
isValidBattleSaveState(validUnlockedOrderCommandState, tacticalOptions),
|
||||
'Expected an unlocked sortie-order command to replace the counterplay requirement.'
|
||||
);
|
||||
assert(
|
||||
isValidBattleSaveState({ ...validUnlockedOrderCommandState, tacticalCommand: undefined }, tacticalOptions),
|
||||
'Expected an unlocked sortie-order command to remain unused in a valid save.'
|
||||
);
|
||||
assert(
|
||||
isValidBattleSaveState({ ...validTacticalBaseState, sortieOrderCommandUnlocked: false }, tacticalOptions),
|
||||
'Expected an explicitly locked sortie-order command without an unlock turn to pass validation.'
|
||||
);
|
||||
const otherSortieOptions = {
|
||||
...options,
|
||||
allowTacticalCommand: true,
|
||||
allowLegacyTacticalCommand: false
|
||||
};
|
||||
const otherSortieLegacyCommandState = {
|
||||
...validFrontCommandState,
|
||||
battleId: options.expectedBattleId,
|
||||
campaignStep: 'first-battle',
|
||||
tacticalCommand: { ...validFrontCommandState.tacticalCommand }
|
||||
};
|
||||
const otherSortieOrderCommandState = {
|
||||
...validUnlockedOrderCommandState,
|
||||
battleId: options.expectedBattleId,
|
||||
campaignStep: 'first-battle'
|
||||
};
|
||||
assert(
|
||||
isValidBattleSaveState(otherSortieOrderCommandState, otherSortieOptions) &&
|
||||
!isValidBattleSaveState(otherSortieLegacyCommandState, otherSortieOptions) &&
|
||||
!isValidBattleSaveState({
|
||||
...otherSortieLegacyCommandState,
|
||||
tacticalCommand: { ...otherSortieLegacyCommandState.tacticalCommand, source: 'initiative' }
|
||||
}, otherSortieOptions),
|
||||
'Expected another sortie battle to allow sortie-order source while rejecting source-less and explicit legacy initiative commands.'
|
||||
);
|
||||
assert(!isValidBattleSaveState(validFrontCommandState, options), 'Expected tactical commands to be rejected when disabled for the battle.');
|
||||
const tacticalRoleOptions = {
|
||||
...tacticalOptions,
|
||||
@@ -131,6 +184,13 @@ try {
|
||||
JSON.stringify(parsedTacticalCommand) === JSON.stringify(validFrontCommandState.tacticalCommand),
|
||||
'Expected every tactical command field to round-trip.'
|
||||
);
|
||||
const parsedOrderCommand = parseBattleSaveState(JSON.stringify(validUnlockedOrderCommandState), tacticalOptions);
|
||||
assert(
|
||||
parsedOrderCommand?.sortieOrderCommandUnlocked === true &&
|
||||
parsedOrderCommand.sortieOrderCommandUnlockTurn === 2 &&
|
||||
parsedOrderCommand.tacticalCommand?.source === 'sortie-order',
|
||||
'Expected sortie-order command unlock and source fields 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',
|
||||
@@ -250,6 +310,7 @@ try {
|
||||
const tacticalRejectedCases = [
|
||||
['invalid tactical command shape', { tacticalCommand: 'front' }],
|
||||
['invalid tactical command role', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, role: 'reserve' } }],
|
||||
['invalid tactical command source', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, source: 'morale' } }],
|
||||
['unknown tactical command actor', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, actorId: 'ghost-unit' } }],
|
||||
['enemy tactical command actor', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, actorId: 'rebel-1' } }],
|
||||
['invalid tactical command turn', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, usedTurn: 0 } }],
|
||||
@@ -275,6 +336,68 @@ try {
|
||||
assert(!isValidBattleSaveState(candidate, tacticalOptions), `Expected invalid tactical command save to be rejected: ${label}`);
|
||||
});
|
||||
|
||||
const orderCommandRejectedCases = [
|
||||
['unlocked command missing unlock turn', { sortieOrderCommandUnlockTurn: undefined }],
|
||||
['zero unlock turn', { sortieOrderCommandUnlockTurn: 0 }],
|
||||
['future unlock turn', { sortieOrderCommandUnlockTurn: 3 }],
|
||||
['fractional unlock turn', { sortieOrderCommandUnlockTurn: 1.5 }],
|
||||
['locked command retaining unlock turn', { sortieOrderCommandUnlocked: false }],
|
||||
['unlocked command without allied actions', {
|
||||
battleStats: {
|
||||
'liu-bei': { ...validState.battleStats['liu-bei'], actions: 0 },
|
||||
'rebel-1': { ...validState.battleStats['liu-bei'], actions: 3 }
|
||||
}
|
||||
}],
|
||||
['sortie-order source without unlock', {
|
||||
sortieOrderCommandUnlocked: undefined,
|
||||
sortieOrderCommandUnlockTurn: undefined
|
||||
}]
|
||||
];
|
||||
orderCommandRejectedCases.forEach(([label, patch]) => {
|
||||
const candidate = { ...validUnlockedOrderCommandState, ...patch };
|
||||
assert(!isValidBattleSaveState(candidate, tacticalOptions), `Expected invalid sortie-order command save to be rejected: ${label}`);
|
||||
});
|
||||
assert(
|
||||
!isValidBattleSaveState(validUnlockedOrderCommandState, options),
|
||||
'Expected sortie-order command unlock state to remain gated by allowTacticalCommand.'
|
||||
);
|
||||
assert(
|
||||
!isValidBattleSaveState(
|
||||
{
|
||||
...validUnlockedOrderCommandState,
|
||||
turnNumber: 3,
|
||||
sortieOrderCommandUnlockTurn: 3,
|
||||
tacticalCommand: { ...validUnlockedOrderCommandState.tacticalCommand, usedTurn: 2 }
|
||||
},
|
||||
tacticalOptions
|
||||
),
|
||||
'Expected a sortie-order tactical command used before its unlock turn to be rejected.'
|
||||
);
|
||||
assert(
|
||||
!isValidBattleSaveState(
|
||||
{
|
||||
...validInitiativeCommandState,
|
||||
battleStats: validState.battleStats
|
||||
},
|
||||
tacticalOptions
|
||||
),
|
||||
'Expected an explicit initiative command to retain the legacy counterplay threshold.'
|
||||
);
|
||||
const unlockedInitiativeState = {
|
||||
...validTacticalBaseState,
|
||||
sortieOrderCommandUnlocked: true,
|
||||
sortieOrderCommandUnlockTurn: 2,
|
||||
tacticalCommand: { ...validInitiativeCommandState.tacticalCommand }
|
||||
};
|
||||
assert(
|
||||
!isValidBattleSaveState(unlockedInitiativeState, tacticalOptions) &&
|
||||
!isValidBattleSaveState({
|
||||
...unlockedInitiativeState,
|
||||
tacticalCommand: { ...unlockedInitiativeState.tacticalCommand, source: undefined }
|
||||
}, tacticalOptions),
|
||||
'Expected unlocked sortie-order state to reject explicit and source-less legacy initiative commands.'
|
||||
);
|
||||
|
||||
assert(
|
||||
!isValidBattleSaveState(validState, { ...options, validUnitIds: new Set(['liu-bei', 'guan-yu', 'rebel-1', 'zhang-fei']) }),
|
||||
'Expected save missing a current battle unit to be rejected.'
|
||||
|
||||
@@ -360,6 +360,62 @@ try {
|
||||
normalizedStringBooleans.progress.every((entry) => entry.achieved === false),
|
||||
`Expected malformed string booleans to stay false instead of restoring an unearned reward claim: ${JSON.stringify(normalizedStringBooleans)}`
|
||||
);
|
||||
const normalizedOrderCommand = normalizeCampaignSortieOrderResultSnapshot({
|
||||
...normalizedEliteOrder,
|
||||
command: {
|
||||
source: 'sortie-order',
|
||||
role: 'support',
|
||||
actorId: ' liu-bei ',
|
||||
usedTurn: '3.9',
|
||||
effectPending: 'true',
|
||||
effectValue: '1000001',
|
||||
statusesCleared: '2.9',
|
||||
effectLost: true,
|
||||
extra: 'discard'
|
||||
}
|
||||
});
|
||||
assert(
|
||||
normalizedOrderCommand?.command?.source === 'sortie-order' &&
|
||||
normalizedOrderCommand.command.role === 'support' &&
|
||||
normalizedOrderCommand.command.actorId === 'liu-bei' &&
|
||||
normalizedOrderCommand.command.usedTurn === 3 &&
|
||||
normalizedOrderCommand.command.effectPending === false &&
|
||||
normalizedOrderCommand.command.effectValue === 999999 &&
|
||||
normalizedOrderCommand.command.statusesCleared === 2 &&
|
||||
normalizedOrderCommand.command.effectLost === true &&
|
||||
!Object.prototype.hasOwnProperty.call(normalizedOrderCommand.command, 'extra') &&
|
||||
!Object.prototype.hasOwnProperty.call(normalizedEliteOrder, 'command'),
|
||||
`Expected sortie-order commands to sanitize ids, numbers, booleans, and preserve command-less legacy snapshots: ${JSON.stringify(normalizedOrderCommand)}`
|
||||
);
|
||||
const invalidOrderCommands = [
|
||||
{ ...normalizedEliteOrder, command: { ...normalizedOrderCommand.command, source: 'morale' } },
|
||||
{ ...normalizedEliteOrder, command: { ...normalizedOrderCommand.command, role: 'reserve' } },
|
||||
{ ...normalizedEliteOrder, command: { ...normalizedOrderCommand.command, actorId: 'x'.repeat(97) } },
|
||||
{ ...normalizedEliteOrder, command: { ...normalizedOrderCommand.command, usedTurn: 0 } }
|
||||
];
|
||||
assert(
|
||||
invalidOrderCommands.every((snapshot) => (
|
||||
!Object.prototype.hasOwnProperty.call(normalizeCampaignSortieOrderResultSnapshot(snapshot) ?? {}, 'command')
|
||||
)),
|
||||
'Expected malformed optional command records to be discarded without losing the sortie-order result.'
|
||||
);
|
||||
const normalizedInitiativeCommand = normalizeCampaignSortieOrderResultSnapshot({
|
||||
...normalizedEliteOrder,
|
||||
command: {
|
||||
...normalizedOrderCommand.command,
|
||||
source: 'initiative',
|
||||
role: 'flank',
|
||||
effectPending: true,
|
||||
effectLost: 'false'
|
||||
}
|
||||
})?.command;
|
||||
assert(
|
||||
normalizedInitiativeCommand?.source === 'initiative' &&
|
||||
normalizedInitiativeCommand.role === 'flank' &&
|
||||
normalizedInitiativeCommand.effectPending === true &&
|
||||
normalizedInitiativeCommand.effectLost === false,
|
||||
`Expected explicit initiative command sources and strict command booleans to normalize: ${JSON.stringify(normalizedInitiativeCommand)}`
|
||||
);
|
||||
|
||||
storage.clear();
|
||||
storage.set(
|
||||
@@ -900,7 +956,17 @@ try {
|
||||
{ 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 }
|
||||
]
|
||||
],
|
||||
command: {
|
||||
source: 'sortie-order',
|
||||
role: 'front',
|
||||
actorId: performanceUnit.id,
|
||||
usedTurn: 3,
|
||||
effectPending: false,
|
||||
effectValue: 8,
|
||||
statusesCleared: 0,
|
||||
effectLost: false
|
||||
}
|
||||
};
|
||||
const successfulMobileOrder = {
|
||||
version: 1,
|
||||
@@ -964,7 +1030,9 @@ try {
|
||||
firstOrderSettlement.claimedSortieOrderRewardIds[0] === `${firstScenario.id}:elite` &&
|
||||
firstOrderSettlement.sortieOrderSelection === undefined &&
|
||||
firstOrderSettlement.firstBattleReport?.sortieOrder?.rewardGranted === true &&
|
||||
firstOrderSettlement.firstBattleReport.sortieOrder.command?.source === 'sortie-order' &&
|
||||
firstOrderSettlement.battleHistory[firstScenario.id]?.sortieOrder?.rewardGranted === true &&
|
||||
firstOrderSettlement.battleHistory[firstScenario.id].sortieOrder.command?.effectValue === 8 &&
|
||||
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)}`
|
||||
);
|
||||
@@ -972,23 +1040,55 @@ try {
|
||||
assert(
|
||||
reloadedFirstOrderSettlement.firstBattleReport?.sortieOrder?.achieved === true &&
|
||||
reloadedFirstOrderSettlement.firstBattleReport.sortieOrder.rewardGranted === true &&
|
||||
reloadedFirstOrderSettlement.firstBattleReport.sortieOrder.command?.actorId === performanceUnit.id &&
|
||||
reloadedFirstOrderSettlement.battleHistory[firstScenario.id]?.sortieOrder?.achieved === true &&
|
||||
reloadedFirstOrderSettlement.battleHistory[firstScenario.id].turnNumber === orderBattleReport.turnNumber &&
|
||||
reloadedFirstOrderSettlement.battleHistory[firstScenario.id].sortieOrder.rewardGranted === true &&
|
||||
reloadedFirstOrderSettlement.battleHistory[firstScenario.id].sortieOrder.command?.effectValue === 8 &&
|
||||
reloadedFirstOrderSettlement.sortieOrderHistory[firstScenario.id]?.elite?.achieved === true &&
|
||||
reloadedFirstOrderSettlement.sortieOrderHistory[firstScenario.id].elite.command?.source === 'sortie-order' &&
|
||||
reloadedFirstOrderSettlement.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`),
|
||||
`Expected report, settlement, independent history, and reward claim to survive save normalization together: ${JSON.stringify(reloadedFirstOrderSettlement)}`
|
||||
);
|
||||
|
||||
const validContextualCommandStateRaw = storage.get(campaignStorageKey);
|
||||
assert(validContextualCommandStateRaw, 'Expected a persisted command state for contextual corruption checks.');
|
||||
const corruptedContextualCommandState = JSON.parse(validContextualCommandStateRaw);
|
||||
corruptedContextualCommandState.firstBattleReport.sortieOrder.command.actorId = 'ghost-unit';
|
||||
corruptedContextualCommandState.battleHistory[firstScenario.id].sortieOrder.command.role = 'support';
|
||||
corruptedContextualCommandState.sortieOrderHistory[firstScenario.id].elite.command.usedTurn = orderBattleReport.turnNumber + 1;
|
||||
storage.set(campaignStorageKey, JSON.stringify(corruptedContextualCommandState));
|
||||
const normalizedContextualCommandState = loadCampaignState();
|
||||
assert(
|
||||
normalizedContextualCommandState.firstBattleReport?.sortieOrder?.command === undefined &&
|
||||
normalizedContextualCommandState.battleHistory[firstScenario.id]?.sortieOrder?.command === undefined &&
|
||||
normalizedContextualCommandState.sortieOrderHistory[firstScenario.id]?.elite?.command === undefined &&
|
||||
normalizedContextualCommandState.firstBattleReport?.sortieOrder?.achieved === true &&
|
||||
normalizedContextualCommandState.battleHistory[firstScenario.id]?.sortieOrder?.achieved === true &&
|
||||
normalizedContextualCommandState.sortieOrderHistory[firstScenario.id]?.elite?.achieved === true,
|
||||
`Expected contextual normalization to remove ghost actors, role mismatches, and future command turns without dropping their order results: ${JSON.stringify(normalizedContextualCommandState)}`
|
||||
);
|
||||
storage.set(campaignStorageKey, validContextualCommandStateRaw);
|
||||
loadCampaignState();
|
||||
|
||||
orderBattleReport.sortieOrder.progress[0].achieved = false;
|
||||
orderBattleReport.sortieOrder.command.effectValue = 999;
|
||||
firstOrderCanonicalReport.sortieOrder.progress[1].achieved = false;
|
||||
firstOrderCanonicalReport.sortieOrder.command.actorId = 'ghost-unit';
|
||||
const deepCopyOrderState = getCampaignState();
|
||||
const detachedOrderState = getCampaignState();
|
||||
detachedOrderState.firstBattleReport.sortieOrder.progress[2].achieved = false;
|
||||
detachedOrderState.firstBattleReport.sortieOrder.command.effectLost = true;
|
||||
assert(
|
||||
deepCopyOrderState.firstBattleReport?.sortieOrder?.progress.every((entry) => entry.achieved) &&
|
||||
deepCopyOrderState.firstBattleReport.sortieOrder.command?.effectValue === 8 &&
|
||||
deepCopyOrderState.firstBattleReport.sortieOrder.command.actorId === performanceUnit.id &&
|
||||
deepCopyOrderState.battleHistory[firstScenario.id]?.sortieOrder?.progress.every((entry) => entry.achieved) &&
|
||||
deepCopyOrderState.battleHistory[firstScenario.id].sortieOrder.command?.effectLost === false &&
|
||||
deepCopyOrderState.sortieOrderHistory[firstScenario.id]?.elite?.progress.every((entry) => entry.achieved) &&
|
||||
deepCopyOrderState.sortieOrderHistory[firstScenario.id].elite.command?.effectValue === 8 &&
|
||||
detachedOrderState.battleHistory[firstScenario.id]?.sortieOrder?.progress[2].achieved === true &&
|
||||
detachedOrderState.battleHistory[firstScenario.id].sortieOrder.command?.effectLost === false &&
|
||||
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)}`
|
||||
);
|
||||
|
||||
@@ -49,6 +49,7 @@ try {
|
||||
|
||||
await page.mouse.click(962, 240);
|
||||
await waitForStoryReady(page);
|
||||
await setDebugQueryParam(page, 'debugOrderCommandReady', '1');
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true });
|
||||
await assertCanvasPainted(page, 'new game story');
|
||||
await advanceUntilBattle(page, 'first-battle-zhuo-commandery');
|
||||
@@ -84,11 +85,36 @@ try {
|
||||
!firstBattleProbe.sideTexts.some((text) => text.includes('...')),
|
||||
`Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}`
|
||||
);
|
||||
await assertLiveSortieOrderHud(page, {
|
||||
const firstBattleOrderHud = await assertLiveSortieOrderHud(page, {
|
||||
battleId: 'first-battle-zhuo-commandery',
|
||||
orderId: 'elite',
|
||||
context: 'first battle'
|
||||
});
|
||||
assert(
|
||||
firstBattleOrderHud.hud?.commandReady === true &&
|
||||
firstBattleOrderHud.hud.commandUnlocked === true &&
|
||||
firstBattleOrderHud.hud.commandUnlockTurn === 1 &&
|
||||
firstBattleOrderHud.hud.commandUsed === false &&
|
||||
firstBattleOrderHud.hud.commandSource === 'sortie-order' &&
|
||||
firstBattleOrderHud.hud.criteria.every((entry) => entry.id === 'victory' || entry.achieved === true) &&
|
||||
firstBattleOrderHud.battleLog.some((entry) => entry.includes('군령 발동 가능')) &&
|
||||
isFiniteBounds(firstBattleOrderHud.hud.commandBounds),
|
||||
`Expected the deterministic fixture to reach the real criteria/action unlock path and expose a ready one-use order command: ${JSON.stringify(firstBattleOrderHud)}`
|
||||
);
|
||||
|
||||
const firstBattleCommand = await activateSortieOrderCommand(page, 'support');
|
||||
assert(
|
||||
firstBattleCommand.live?.commandReady === false &&
|
||||
firstBattleCommand.live?.commandUnlocked === true &&
|
||||
firstBattleCommand.live?.commandUsed === true &&
|
||||
firstBattleCommand.live?.commandSource === 'sortie-order' &&
|
||||
firstBattleCommand.live?.commandActorId === firstBattleCommand.live?.command?.actorId &&
|
||||
firstBattleCommand.live?.command?.source === 'sortie-order' &&
|
||||
firstBattleCommand.live?.command?.role === 'support' &&
|
||||
firstBattleCommand.live?.command?.effectPending === false &&
|
||||
firstBattleCommand.live?.command?.effectValue > 0,
|
||||
`Expected selecting 재정비 to consume the order command and resolve deterministic healing immediately: ${JSON.stringify(firstBattleCommand)}`
|
||||
);
|
||||
|
||||
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
|
||||
await waitForBattleOutcome(page, 'victory');
|
||||
@@ -120,6 +146,7 @@ try {
|
||||
const firstResultSave = await readCampaignSave(page);
|
||||
const firstSortieOrder = firstResultState?.sortieOperationOrder;
|
||||
const firstSortieOrderResult = firstSortieOrder?.result;
|
||||
const firstSortieOrderCommand = firstSortieOrderResult?.command;
|
||||
assert(
|
||||
firstSortieOrder?.selectedId === 'elite' &&
|
||||
firstSortieOrder?.open === false &&
|
||||
@@ -128,6 +155,17 @@ try {
|
||||
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(
|
||||
firstSortieOrderCommand?.source === 'sortie-order' &&
|
||||
firstSortieOrderCommand.role === 'support' &&
|
||||
typeof firstSortieOrderCommand.actorId === 'string' && firstSortieOrderCommand.actorId.length > 0 &&
|
||||
firstSortieOrderCommand.usedTurn === 1 &&
|
||||
firstSortieOrderCommand.effectPending === false &&
|
||||
firstSortieOrderCommand.effectValue > 0 &&
|
||||
firstSortieOrderCommand.statusesCleared >= 0 &&
|
||||
firstSortieOrderCommand.effectLost === false,
|
||||
`Expected the result snapshot to retain the resolved 재정비 order command: ${JSON.stringify(firstSortieOrderResult)}`
|
||||
);
|
||||
assert(
|
||||
sameJsonValue(firstResultSave.current?.firstBattleReport?.sortieOrder, firstSortieOrderResult) &&
|
||||
sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortieOrder, firstSortieOrderResult) &&
|
||||
@@ -136,6 +174,15 @@ try {
|
||||
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)}`
|
||||
);
|
||||
assert(
|
||||
sameJsonValue(firstResultSave.current?.firstBattleReport?.sortieOrder?.command, firstSortieOrderCommand) &&
|
||||
sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortieOrder?.command, firstSortieOrderCommand) &&
|
||||
sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder?.command, firstSortieOrderCommand) &&
|
||||
sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder?.command, firstSortieOrderCommand) &&
|
||||
sameJsonValue(firstResultSave.current?.sortieOrderHistory?.['first-battle-zhuo-commandery']?.elite?.command, firstSortieOrderCommand) &&
|
||||
sameJsonValue(firstResultSave.slot1?.sortieOrderHistory?.['first-battle-zhuo-commandery']?.elite?.command, firstSortieOrderCommand),
|
||||
`Expected the order-command snapshot to persist across current/slot reports, settlements, and order history: ${JSON.stringify(firstResultSave)}`
|
||||
);
|
||||
if (firstSortieOrderResult.achieved) {
|
||||
assert(
|
||||
firstSortieOrderResult.rewardGranted === true &&
|
||||
@@ -212,8 +259,10 @@ try {
|
||||
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)}`
|
||||
firstOrderDetail.texts.some((text) => text.includes('출전 전원 생존')) &&
|
||||
firstOrderDetail.texts.some((text) => text.includes('군령 발동') && text.includes('재정비')) &&
|
||||
firstOrderDetail.texts.some((text) => text.includes('1턴') && text.includes('회복')),
|
||||
`Expected the visible sortie-order detail to explain every elite condition and its used command: ${JSON.stringify(firstOrderDetail)}`
|
||||
);
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-sortie-order.png`, fullPage: true });
|
||||
const firstOrderClosePoint = await readBattleSortieOrderControlPoint(page, 'close');
|
||||
@@ -297,6 +346,10 @@ try {
|
||||
);
|
||||
assert(firstCampProbe.summary?.includes('보유 군자금'), `Expected camp summary to show held gold: ${JSON.stringify(firstCampProbe)}`);
|
||||
assert(firstCampProbe.texts.some((text) => text.includes('전투 보상')), `Expected camp report to show battle reward: ${JSON.stringify(firstCampProbe.texts)}`);
|
||||
assert(
|
||||
firstCampProbe.texts.some((text) => text.includes('명령 기록') && text.includes('재정비')),
|
||||
`Expected the camp report to show the persisted command record: ${JSON.stringify(firstCampProbe.texts)}`
|
||||
);
|
||||
assert(
|
||||
firstCampProbe.texts.some((text) => text.includes(`다음 ${firstBattleUnlockLabel}`)),
|
||||
`Expected camp report to surface first battle unlock label: ${JSON.stringify(firstCampProbe.texts)}`
|
||||
@@ -346,14 +399,21 @@ try {
|
||||
firstCampOrder.orderId === 'elite' &&
|
||||
firstCampOrder.achieved === firstSortieOrderResult.achieved &&
|
||||
firstCampOrder.firstRewardGranted === firstSortieOrderResult.rewardGranted &&
|
||||
sameSortieOrderCommand(firstCampOrder.command, firstSortieOrderCommand) &&
|
||||
firstCampOrder.command?.label === '재정비' &&
|
||||
firstCampOrder.command?.sourceLabel === '군령 발동' &&
|
||||
firstCampOrder.command?.recordLine?.includes('회복') &&
|
||||
firstCampHistory.records[0].sortieOrder?.orderId === 'elite' &&
|
||||
firstCampHistory.records[0].sortieOrder?.achieved === firstSortieOrderResult.achieved &&
|
||||
sameSortieOrderCommand(firstCampHistory.records[0].sortieOrder?.command, firstSortieOrderCommand) &&
|
||||
firstCampHistory.selected?.sortieOrder?.progress?.length === 3 &&
|
||||
sameSortieOrderCommand(firstCampHistory.selected?.sortieOrder?.command, firstSortieOrderCommand) &&
|
||||
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 })}`
|
||||
);
|
||||
await setDebugQueryParam(page, 'debugOrderCommandReady', null);
|
||||
if (firstSortieOrderResult.rewardGranted) {
|
||||
assert(
|
||||
firstCampOrder.rewardBadgeBounds && firstCampOrder.rewardLine.includes('상처약 1'),
|
||||
@@ -524,6 +584,7 @@ try {
|
||||
firstCampHistoryOpenProbe.texts.includes('최고 편성') &&
|
||||
firstCampHistoryOpenProbe.texts.includes('편성책 평균 · 군령 전적') &&
|
||||
firstCampHistoryOpenProbe.texts.includes('현재 출전안 vs 최고 편성') &&
|
||||
firstCampHistoryOpenProbe.texts.some((text) => text.includes('명령 기록') && text.includes('재정비') && text.includes('군령 발동')) &&
|
||||
sameJsonValue(firstCampHistorySaveOpen, firstCampHistorySaveBeforeOpen),
|
||||
`Expected opening the first camp formation history to remain non-destructive and expose best/load controls: ${JSON.stringify(firstCampHistoryOpenProbe)}`
|
||||
);
|
||||
@@ -2281,6 +2342,18 @@ function withDebugParam(url) {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function setDebugQueryParam(page, key, value) {
|
||||
await page.evaluate(({ key, value }) => {
|
||||
const url = new URL(window.location.href);
|
||||
if (value === null) {
|
||||
url.searchParams.delete(key);
|
||||
} else {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
window.history.replaceState(window.history.state, '', url);
|
||||
}, { key, value });
|
||||
}
|
||||
|
||||
function isWarning(type) {
|
||||
return type === 'warning' || type === 'warn';
|
||||
}
|
||||
@@ -2414,6 +2487,14 @@ async function assertLiveSortieOrderHud(page, { battleId, orderId, context }) {
|
||||
status: rawHud.status ?? '',
|
||||
tone: rawHud.tone ?? rawHud.status ?? '',
|
||||
projectedScore: rawHud.projectedScore,
|
||||
commandReady: rawHud.commandReady,
|
||||
commandUnlocked: rawHud.commandUnlocked,
|
||||
commandUnlockTurn: rawHud.commandUnlockTurn,
|
||||
commandUsed: rawHud.commandUsed,
|
||||
commandSource: rawHud.commandSource,
|
||||
commandActorId: rawHud.commandActorId,
|
||||
commandBounds: rawHud.commandBounds ?? null,
|
||||
command: rawHud.command ? { ...rawHud.command } : null,
|
||||
criteria: criteria.map((entry) => ({
|
||||
id: entry?.id ?? null,
|
||||
label: entry?.label ?? '',
|
||||
@@ -2427,7 +2508,8 @@ async function assertLiveSortieOrderHud(page, { battleId, orderId, context }) {
|
||||
}
|
||||
: null,
|
||||
expectedBattleId,
|
||||
expectedOrderId
|
||||
expectedOrderId,
|
||||
battleLog: Array.isArray(state?.battleLog) ? [...state.battleLog] : []
|
||||
};
|
||||
}, { expectedBattleId: battleId, expectedOrderId: orderId });
|
||||
|
||||
@@ -2451,7 +2533,14 @@ async function assertLiveSortieOrderHud(page, { battleId, orderId, context }) {
|
||||
typeof hud.victoryTone === 'string' && hud.victoryTone.length > 0 &&
|
||||
typeof hud.status === 'string' && hud.status.length > 0 &&
|
||||
typeof hud.tone === 'string' && hud.tone.length > 0 &&
|
||||
Number.isFinite(hud.projectedScore),
|
||||
Number.isFinite(hud.projectedScore) &&
|
||||
typeof hud.commandReady === 'boolean' &&
|
||||
typeof hud.commandUnlocked === 'boolean' &&
|
||||
typeof hud.commandUsed === 'boolean' &&
|
||||
(hud.commandUnlockTurn === null || Number.isInteger(hud.commandUnlockTurn)) &&
|
||||
(hud.commandSource === null || hud.commandSource === 'sortie-order' || hud.commandSource === 'initiative') &&
|
||||
(hud.commandActorId === null || typeof hud.commandActorId === 'string') &&
|
||||
(hud.commandBounds === null || (isFiniteBounds(hud.commandBounds) && boundsInside(hud.commandBounds, hud.bounds))),
|
||||
`Expected ${context} to show a fully labelled live ${orderId} HUD with a finite projected score: ${JSON.stringify(probe)}`
|
||||
);
|
||||
assert(
|
||||
@@ -2480,6 +2569,93 @@ async function assertLiveSortieOrderHud(page, { battleId, orderId, context }) {
|
||||
return probe;
|
||||
}
|
||||
|
||||
async function activateSortieOrderCommand(page, role) {
|
||||
await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
scene?.hideBattleEventBanner?.();
|
||||
});
|
||||
const openPoint = await readBattleOrderCommandControlPoint(page, 'open');
|
||||
await page.mouse.click(openPoint.x, openPoint.y);
|
||||
await page.waitForFunction(
|
||||
() => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder?.commandPanelOpen === true,
|
||||
undefined,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
const panelProbe = await page.evaluate(() => {
|
||||
const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder;
|
||||
const cards = Object.fromEntries(Object.entries(order?.commandCards ?? {}).map(([cardRole, rawCard]) => [
|
||||
cardRole,
|
||||
rawCard && typeof rawCard === 'object' && 'bounds' in rawCard
|
||||
? { ...rawCard, bounds: rawCard.bounds }
|
||||
: { role: cardRole, bounds: rawCard }
|
||||
]));
|
||||
return {
|
||||
open: order?.commandPanelOpen ?? false,
|
||||
panelBounds: order?.commandPanelBounds ?? null,
|
||||
cards
|
||||
};
|
||||
});
|
||||
assert(
|
||||
panelProbe.open === true &&
|
||||
isFiniteBounds(panelProbe.panelBounds) &&
|
||||
['front', 'flank', 'support'].every((cardRole) => (
|
||||
isFiniteBounds(panelProbe.cards?.[cardRole]?.bounds) &&
|
||||
boundsInside(panelProbe.cards[cardRole].bounds, panelProbe.panelBounds)
|
||||
)) &&
|
||||
panelProbe.cards?.[role]?.enabled !== false,
|
||||
`Expected the ready order command panel to expose three bounded role choices: ${JSON.stringify(panelProbe)}`
|
||||
);
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-order-command.png`, fullPage: true });
|
||||
await assertCanvasPainted(page, 'first battle order command');
|
||||
|
||||
const rolePoint = await readBattleOrderCommandControlPoint(page, 'card', role);
|
||||
await page.mouse.click(rolePoint.x, rolePoint.y);
|
||||
await page.waitForFunction(
|
||||
(expectedRole) => {
|
||||
const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder;
|
||||
return (
|
||||
order?.commandPanelOpen === false &&
|
||||
order?.live?.commandUsed === true &&
|
||||
order?.live?.command?.role === expectedRole
|
||||
);
|
||||
},
|
||||
role,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
return page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder);
|
||||
}
|
||||
|
||||
async function readBattleOrderCommandControlPoint(page, control, role) {
|
||||
const probe = await page.evaluate(({ control, role }) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
const canvas = document.querySelector('canvas');
|
||||
const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder;
|
||||
const rawCard = role ? order?.commandCards?.[role] : undefined;
|
||||
const cardBounds = rawCard && typeof rawCard === 'object' && 'bounds' in rawCard ? rawCard.bounds : rawCard;
|
||||
const bounds = control === 'open' ? order?.live?.commandBounds : cardBounds;
|
||||
if (!scene || !canvas || !bounds) {
|
||||
return { ready: false, control, role: role ?? 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,
|
||||
role: role ?? null,
|
||||
bounds,
|
||||
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
||||
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
||||
};
|
||||
}, { control, role });
|
||||
assert(
|
||||
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
||||
`Expected a visible battle order-command ${control} control: ${JSON.stringify(probe)}`
|
||||
);
|
||||
return probe;
|
||||
}
|
||||
|
||||
async function waitForBattleOutcome(page, outcome) {
|
||||
await page.waitForFunction((expectedOutcome) => {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
@@ -3178,6 +3354,23 @@ function sameJsonValue(value, expected) {
|
||||
return stableJson(value) === stableJson(expected);
|
||||
}
|
||||
|
||||
function sameSortieOrderCommand(value, expected) {
|
||||
const commandFields = [
|
||||
'source',
|
||||
'role',
|
||||
'actorId',
|
||||
'usedTurn',
|
||||
'effectPending',
|
||||
'effectValue',
|
||||
'statusesCleared',
|
||||
'effectLost'
|
||||
];
|
||||
const snapshot = (command) => command
|
||||
? Object.fromEntries(commandFields.map((field) => [field, command[field]]))
|
||||
: null;
|
||||
return sameJsonValue(snapshot(value), snapshot(expected));
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
return JSON.stringify(normalize(value));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user