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

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