feat: unlock tactical commands from sortie orders

This commit is contained in:
2026-07-11 02:42:04 +09:00
parent 9f49069db2
commit 2b9e33f4c0
8 changed files with 1060 additions and 70 deletions

View File

@@ -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.'

View File

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

View File

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