1140 lines
54 KiB
JavaScript
1140 lines
54 KiB
JavaScript
import { spawn, spawnSync } from 'node:child_process';
|
|
import { mkdirSync } from 'node:fs';
|
|
import { chromium } from 'playwright';
|
|
|
|
const targetUrl = withDebugParam(process.env.RELEASE_QA_URL ?? 'http://127.0.0.1:4173/');
|
|
const screenshotDir = 'dist';
|
|
const expectedTitle = '\uC0BC\uAD6D\uC9C0: \uC138 \uD615\uC81C\uC758 \uB9F9\uC138';
|
|
const firstBattleUnlockLabel = '\uD669\uAC74 \uC794\uB2F9 \uCD94\uACA9 \uAC1C\uBC29';
|
|
const relevantLogPattern = /asset|audio|cannot|failed|map|missing|portrait|sprite|story|texture|unit/i;
|
|
|
|
let serverProcess;
|
|
let browser;
|
|
|
|
try {
|
|
runCommand('node', ['scripts/verify-static-data.mjs']);
|
|
mkdirSync(screenshotDir, { recursive: true });
|
|
serverProcess = await ensurePreviewServer(targetUrl);
|
|
runCommand(process.execPath, ['scripts/verify-save-retry-flow.mjs'], { VERIFY_URL: targetUrl });
|
|
|
|
browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
|
const consoleMessages = [];
|
|
const pageErrors = [];
|
|
const requestFailures = [];
|
|
page.on('console', (message) => {
|
|
consoleMessages.push({ type: message.type(), text: message.text() });
|
|
});
|
|
page.on('pageerror', (error) => {
|
|
pageErrors.push(error.message);
|
|
});
|
|
page.on('requestfailed', (request) => {
|
|
requestFailures.push({
|
|
url: request.url(),
|
|
type: request.resourceType(),
|
|
failure: request.failure()?.errorText ?? 'unknown'
|
|
});
|
|
});
|
|
|
|
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate(() => window.localStorage.clear());
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await assertReleaseShellReady(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-title-first-run.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'title first run');
|
|
|
|
const emptySave = await readCampaignSave(page);
|
|
assert(!emptySave.current && !emptySave.slot1, `Expected first run without campaign save: ${JSON.stringify(emptySave)}`);
|
|
|
|
await page.mouse.click(962, 240);
|
|
await waitForStoryReady(page);
|
|
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');
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-battle.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first battle');
|
|
|
|
const firstBattleProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
const sideTexts = textValues(scene?.sidePanelObjects);
|
|
return {
|
|
battleId: state?.battleId,
|
|
objectiveText: scene?.objectiveTrackerText?.text,
|
|
objectiveSubText: scene?.objectiveTrackerSubText?.text,
|
|
sideTexts
|
|
};
|
|
|
|
function textValues(objects = []) {
|
|
return objects.filter((object) => object?.type === 'Text').map((object) => object.text);
|
|
}
|
|
});
|
|
assert(firstBattleProbe.battleId === 'first-battle-zhuo-commandery', `Expected first battle: ${JSON.stringify(firstBattleProbe)}`);
|
|
assert(firstBattleProbe.objectiveText?.startsWith('승리 목표:'), `Expected clear victory objective label: ${JSON.stringify(firstBattleProbe)}`);
|
|
assert(firstBattleProbe.objectiveSubText?.includes('패배 조건:'), `Expected clear defeat condition label: ${JSON.stringify(firstBattleProbe)}`);
|
|
assert(firstBattleProbe.objectiveSubText?.includes('진군:'), `Expected tactical route in objective tracker: ${JSON.stringify(firstBattleProbe)}`);
|
|
assert(
|
|
firstBattleProbe.sideTexts.some((text) => text.includes('출진 군세')) &&
|
|
!firstBattleProbe.sideTexts.some((text) => text.includes('...')),
|
|
`Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}`
|
|
);
|
|
|
|
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
|
|
await waitForBattleOutcome(page, 'victory');
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-result.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first battle result');
|
|
|
|
const resultProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return {
|
|
outcome: state?.battleOutcome,
|
|
resultTexts: textValues(scene?.resultObjects)
|
|
};
|
|
|
|
function textValues(objects = []) {
|
|
return objects.filter((object) => object?.type === 'Text').map((object) => object.text);
|
|
}
|
|
});
|
|
assert(resultProbe.outcome === 'victory', `Expected forced victory result: ${JSON.stringify(resultProbe)}`);
|
|
assert(resultProbe.resultTexts.includes('목표 정산'), `Expected result objective settlement title: ${JSON.stringify(resultProbe.resultTexts)}`);
|
|
assert(resultProbe.resultTexts.some((text) => text.includes('목표 보상')), `Expected reward panel to name objective rewards: ${JSON.stringify(resultProbe.resultTexts)}`);
|
|
assert(
|
|
resultProbe.resultTexts.some((text) => text.includes(`해금 ${firstBattleUnlockLabel}`)),
|
|
`Expected result reward panel to surface first battle unlock label: ${JSON.stringify(resultProbe.resultTexts)}`
|
|
);
|
|
assert(!resultProbe.resultTexts.some((text) => text.includes('미달')), `Expected result screen to avoid harsh optional-goal wording: ${JSON.stringify(resultProbe.resultTexts)}`);
|
|
|
|
await page.mouse.click(738, 642);
|
|
await waitForCampAfterBattleResult(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first camp');
|
|
|
|
const firstCampProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
return {
|
|
state: window.__HEROS_DEBUG__?.camp(),
|
|
summary: scene?.reportSummary?.(),
|
|
texts: textValues(scene?.contentObjects)
|
|
};
|
|
|
|
function textValues(objects = []) {
|
|
return objects.filter((object) => object?.type === 'Text').map((object) => object.text);
|
|
}
|
|
});
|
|
assert(firstCampProbe.state?.campaign?.step === 'first-camp', `Expected victory to return to first camp: ${JSON.stringify(firstCampProbe.state)}`);
|
|
assert(
|
|
firstCampProbe.state?.nextSortieBattleId === 'second-battle-yellow-turban-pursuit',
|
|
`Expected first camp sortie flow to prepare unlocked second battle: ${JSON.stringify(firstCampProbe.state)}`
|
|
);
|
|
assert(
|
|
['liu-bei', 'guan-yu', 'zhang-fei'].every((unitId) =>
|
|
firstCampProbe.state?.sortieDeploymentPreview?.some((slot) => slot.unitId === unitId)
|
|
),
|
|
`Expected first camp deployment preview to keep the founding trio assigned: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}`
|
|
);
|
|
assert(firstCampProbe.state?.sortieHasBattle === true, `Expected first camp to expose a playable next battle: ${JSON.stringify(firstCampProbe.state)}`);
|
|
assert(
|
|
firstCampProbe.state?.sortieRecommendationEnabled === true,
|
|
`Expected first camp to support recommended sortie planning: ${JSON.stringify(firstCampProbe.state)}`
|
|
);
|
|
assert(
|
|
firstCampProbe.state?.sortiePlan?.selectedCount === firstCampProbe.state?.sortieDeploymentPreview?.length,
|
|
`Expected deployment preview to match selected sortie count: ${JSON.stringify(firstCampProbe.state?.sortiePlan)} / ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}`
|
|
);
|
|
assertUnique(
|
|
firstCampProbe.state?.sortieDeploymentPreview?.map((slot) => slot.unitId),
|
|
`Expected first camp deployment preview to avoid duplicate units: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}`
|
|
);
|
|
assertUnique(
|
|
firstCampProbe.state?.sortieDeploymentPreview?.map((slot) => `${slot.x},${slot.y}`),
|
|
`Expected first camp deployment preview to avoid duplicate tiles: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}`
|
|
);
|
|
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(`다음 ${firstBattleUnlockLabel}`)),
|
|
`Expected camp report to surface first battle unlock label: ${JSON.stringify(firstCampProbe.texts)}`
|
|
);
|
|
assert(
|
|
firstCampProbe.state?.report?.campaignRewards?.unlocks?.some(
|
|
(unlock) => unlock.battleId === 'second-battle-yellow-turban-pursuit' && unlock.title === firstBattleUnlockLabel
|
|
),
|
|
`Expected campaign report debug state to retain first battle unlock reward: ${JSON.stringify(firstCampProbe.state?.report?.campaignRewards)}`
|
|
);
|
|
|
|
const firstCampSelectedSortieUnitIds = firstCampProbe.state?.selectedSortieUnitIds ?? [];
|
|
const firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? [];
|
|
await page.mouse.click(1160, 38);
|
|
await waitForSortiePrep(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-prep.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first camp sortie prep');
|
|
const firstSortieBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
firstSortieBriefingState?.stagedSortiePrep === true &&
|
|
firstSortieBriefingState?.sortiePrepStep === 'briefing' &&
|
|
firstSortieBriefingState?.sortiePrimaryAction?.kind === 'next' &&
|
|
firstSortieBriefingState?.sortiePrimaryAction?.nextStep === 'formation',
|
|
`Expected the first sortie to open at the staged battlefield briefing: ${JSON.stringify(firstSortieBriefingState)}`
|
|
);
|
|
await advanceSortiePrepStep(page, 'formation');
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-formation.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first camp sortie formation');
|
|
const firstSortieSynergy = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieSynergyPreview);
|
|
assert(
|
|
firstSortieSynergy?.activeBondCount === 3 &&
|
|
firstSortieSynergy?.coveredRoleCount === 3 &&
|
|
firstSortieSynergy?.strongestBond?.damageBonus === 9 &&
|
|
firstSortieSynergy?.strongestBond?.chainRate === 18 &&
|
|
firstSortieSynergy?.firstPursuitTrinityConfigured === true,
|
|
`Expected first sortie to preview three bonds, the strongest effect, and trinity: ${JSON.stringify(firstSortieSynergy)}`
|
|
);
|
|
await advanceSortiePrepStep(page, 'loadout');
|
|
await page.mouse.click(1116, 656);
|
|
await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit');
|
|
await page.screenshot({ path: `${screenshotDir}/rc-second-battle-from-first-camp.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'second battle from first camp');
|
|
|
|
const secondBattleProbe = await readBattleDoctrineProbe(page);
|
|
assert(secondBattleProbe?.battleId === 'second-battle-yellow-turban-pursuit', `Expected first camp sortie to enter second battle: ${JSON.stringify(secondBattleProbe)}`);
|
|
assertSameMembers(
|
|
secondBattleProbe?.selectedSortieUnitIds,
|
|
firstCampSelectedSortieUnitIds,
|
|
`Expected second battle to receive first camp selected sortie ids: ${JSON.stringify(secondBattleProbe?.selectedSortieUnitIds)} / ${JSON.stringify(firstCampSelectedSortieUnitIds)}`
|
|
);
|
|
assertSameMembers(
|
|
secondBattleProbe?.deployedAllyIds,
|
|
firstCampSelectedSortieUnitIds,
|
|
`Expected second battle deployed allies to match first camp sortie ids: ${JSON.stringify(secondBattleProbe?.deployedAllyIds)} / ${JSON.stringify(firstCampSelectedSortieUnitIds)}`
|
|
);
|
|
assertDeploymentMatchesPreview(
|
|
secondBattleProbe?.deployedAllyPositions,
|
|
firstCampDeploymentPreview,
|
|
`Expected second battle ally positions to match first camp deployment preview: ${JSON.stringify(secondBattleProbe?.deployedAllyPositions)} / ${JSON.stringify(firstCampDeploymentPreview)}`
|
|
);
|
|
assertSortieDoctrine(secondBattleProbe, { requireFullCoverage: true, requireVisibleSeals: true });
|
|
assert(
|
|
secondBattleProbe.firstSortieRoleEffects?.active === true &&
|
|
secondBattleProbe.firstSortieRoleEffects?.fullCoverage === true &&
|
|
secondBattleProbe.firstSortieRoleEffects?.trinityActive === true &&
|
|
secondBattleProbe.firstSortieRoleEffects?.trinitySupportRange === 2,
|
|
`Expected the second battle to retain its three-role Peach Garden trinity special: ${JSON.stringify(secondBattleProbe.firstSortieRoleEffects)}`
|
|
);
|
|
assert(
|
|
secondBattleProbe.sortieDoctrine?.roles?.every((role) => role.unitIds.length === 1) &&
|
|
secondBattleProbe.sortieDoctrine?.activeBondCount === 3,
|
|
`Expected the second battle doctrine to expose one officer per role and all three active bonds: ${JSON.stringify(secondBattleProbe.sortieDoctrine)}`
|
|
);
|
|
assert(
|
|
secondBattleProbe.openingBannerTexts?.some((text) => text.includes('세 형제 생존 중 공명 지원 거리 2칸')) &&
|
|
secondBattleProbe.openingBannerTexts?.some((text) => text.includes('전술 명령')),
|
|
`Expected the second battle opening banner to retain trinity and tactical-command guidance: ${JSON.stringify(secondBattleProbe.openingBannerTexts)}`
|
|
);
|
|
const roleSealResyncProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
if (!scene) {
|
|
return undefined;
|
|
}
|
|
const officerIds = ['liu-bei', 'guan-yu', 'zhang-fei'];
|
|
const campaignCurrentKey = 'heros-web:campaign-state';
|
|
const campaignSlotKey = `${campaignCurrentKey}:slot-1`;
|
|
const battleKey = 'heros-web:battle:second-battle-yellow-turban-pursuit';
|
|
const storageKeys = [campaignCurrentKey, campaignSlotKey, battleKey, `${battleKey}:slot-1`, 'heros-web:first-battle-state'];
|
|
const storageSnapshot = new Map(storageKeys.map((key) => [key, window.localStorage.getItem(key)]));
|
|
const captureUnits = () => {
|
|
const byId = new Map((window.__HEROS_DEBUG__?.battle()?.units ?? []).map((unit) => [unit.id, unit]));
|
|
return officerIds.map((id) => {
|
|
const unit = byId.get(id);
|
|
return {
|
|
id,
|
|
role: unit?.formationRole ?? null,
|
|
effect: unit?.sortieRoleEffect?.label ?? null,
|
|
seal: unit?.roleSealText ?? null,
|
|
sealPresent: unit?.roleSealPresent ?? false,
|
|
sealVisible: unit?.roleSealVisible ?? false
|
|
};
|
|
});
|
|
};
|
|
const loadAssignments = (savedCampaignRaw, assignments) => {
|
|
const campaign = JSON.parse(savedCampaignRaw);
|
|
campaign.sortieFormationAssignments = assignments;
|
|
window.localStorage.setItem(campaignSlotKey, JSON.stringify(campaign));
|
|
scene.loadBattleState?.(1);
|
|
};
|
|
|
|
scene.saveBattleState?.(1);
|
|
const savedCampaignRaw = window.localStorage.getItem(campaignSlotKey);
|
|
if (!savedCampaignRaw) {
|
|
return { error: 'campaign slot save missing' };
|
|
}
|
|
|
|
loadAssignments(savedCampaignRaw, { 'liu-bei': 'front', 'guan-yu': 'support', 'zhang-fei': 'reserve' });
|
|
const changed = captureUnits();
|
|
|
|
loadAssignments(savedCampaignRaw, { 'liu-bei': 'reserve', 'guan-yu': 'reserve', 'zhang-fei': 'reserve' });
|
|
scene.hideBattleEventBanner?.();
|
|
scene.triggeredBattleEvents?.delete('opening');
|
|
scene.showOpeningBattleEvent?.();
|
|
const allReserve = {
|
|
doctrineActive: window.__HEROS_DEBUG__?.battle()?.sortieDoctrine?.active ?? false,
|
|
coveredRoleCount: window.__HEROS_DEBUG__?.battle()?.sortieDoctrine?.coveredRoleCount ?? -1,
|
|
bannerTexts: (scene.battleEventObjects ?? [])
|
|
.filter((object) => object?.type === 'Text')
|
|
.map((object) => object.text)
|
|
};
|
|
scene.hideBattleEventBanner?.();
|
|
|
|
window.localStorage.setItem(campaignSlotKey, savedCampaignRaw);
|
|
scene.loadBattleState?.(1);
|
|
const restored = captureUnits();
|
|
storageSnapshot.forEach((value, key) => {
|
|
if (value === null) {
|
|
window.localStorage.removeItem(key);
|
|
} else {
|
|
window.localStorage.setItem(key, value);
|
|
}
|
|
});
|
|
return { changed, allReserve, restored };
|
|
});
|
|
assert(
|
|
JSON.stringify(roleSealResyncProbe?.changed) === JSON.stringify([
|
|
{ id: 'liu-bei', role: 'front', effect: '전열 방호', seal: '전', sealPresent: true, sealVisible: true },
|
|
{ id: 'guan-yu', role: 'support', effect: '후원 지휘', seal: '후', sealPresent: true, sealVisible: true },
|
|
{ id: 'zhang-fei', role: 'reserve', effect: null, seal: null, sealPresent: false, sealVisible: false }
|
|
]),
|
|
`Expected role seals to resync when a loaded formation changes roles: ${JSON.stringify(roleSealResyncProbe)}`
|
|
);
|
|
assert(
|
|
roleSealResyncProbe?.allReserve?.doctrineActive === true &&
|
|
roleSealResyncProbe?.allReserve?.coveredRoleCount === 0 &&
|
|
roleSealResyncProbe?.allReserve?.bannerTexts?.some((text) => text.startsWith('출진 군세')),
|
|
`Expected an all-reserve formation to keep the sortie-doctrine opening banner: ${JSON.stringify(roleSealResyncProbe?.allReserve)}`
|
|
);
|
|
assert(
|
|
JSON.stringify(roleSealResyncProbe?.restored) === JSON.stringify([
|
|
{ id: 'liu-bei', role: 'support', effect: '후원 지휘', seal: '후', sealPresent: true, sealVisible: true },
|
|
{ id: 'guan-yu', role: 'front', effect: '전열 방호', seal: '전', sealPresent: true, sealVisible: true },
|
|
{ id: 'zhang-fei', role: 'flank', effect: '돌파 선봉', seal: '돌', sealPresent: true, sealVisible: true }
|
|
]),
|
|
`Expected loaded role seals to restore the original formation cleanly: ${JSON.stringify(roleSealResyncProbe?.restored)}`
|
|
);
|
|
|
|
await seedCampaignSave(page, {
|
|
battleId: 'fifty-eighth-battle-qishan-retreat',
|
|
battleTitle: '기산 후퇴로',
|
|
step: 'fifty-eighth-camp',
|
|
gold: 7400,
|
|
turnNumber: 13,
|
|
defeatedEnemies: 16,
|
|
selectedSortieUnitIds: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'zhao-yun', 'huang-quan', 'ma-liang', 'ma-dai']
|
|
});
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await page.mouse.click(962, 310);
|
|
await waitForCamp(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-campaign-continue.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'mid campaign camp');
|
|
|
|
const midCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(midCampState?.campaign?.step === 'fifty-eighth-camp', `Expected mid-campaign save to continue into camp: ${JSON.stringify(midCampState)}`);
|
|
assert(midCampState.campBattleId === 'fifty-eighth-battle-qishan-retreat', `Expected mid-campaign battle id: ${JSON.stringify(midCampState)}`);
|
|
assert(midCampState.sortiePlan?.selectedCount > 0, `Expected mid-campaign formation state: ${JSON.stringify(midCampState?.sortiePlan)}`);
|
|
assert(midCampState.sortieHasBattle === true, `Expected mid-campaign camp to keep a playable sortie target: ${JSON.stringify(midCampState)}`);
|
|
assert(
|
|
typeof midCampState.nextSortieBattleId === 'string' && midCampState.nextSortieBattleId.length > 0,
|
|
`Expected mid-campaign sortie flow to expose a battle id: ${JSON.stringify(midCampState)}`
|
|
);
|
|
assert(
|
|
midCampState.sortiePlan?.selectedCount <= midCampState.sortiePlan?.maxCount,
|
|
`Expected mid-campaign sortie selection to stay within limit: ${JSON.stringify(midCampState?.sortiePlan)}`
|
|
);
|
|
assert(
|
|
midCampState.sortiePlan?.selectedCount === midCampState.sortieDeploymentPreview?.length,
|
|
`Expected mid-campaign deployment preview to match selected sortie count: ${JSON.stringify(midCampState?.sortiePlan)} / ${JSON.stringify(midCampState?.sortieDeploymentPreview)}`
|
|
);
|
|
assertUnique(
|
|
midCampState.sortieDeploymentPreview?.map((slot) => slot.unitId),
|
|
`Expected mid-campaign deployment preview to avoid duplicate units: ${JSON.stringify(midCampState?.sortieDeploymentPreview)}`
|
|
);
|
|
assertUnique(
|
|
midCampState.sortieDeploymentPreview?.map((slot) => `${slot.x},${slot.y}`),
|
|
`Expected mid-campaign deployment preview to avoid duplicate tiles: ${JSON.stringify(midCampState?.sortieDeploymentPreview)}`
|
|
);
|
|
|
|
const midCampNextBattleId = midCampState.nextSortieBattleId;
|
|
await page.mouse.click(1160, 38);
|
|
await waitForSortiePrep(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-prep.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'mid camp sortie prep');
|
|
|
|
const midBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
midBriefingState?.stagedSortiePrep === true &&
|
|
midBriefingState?.firstSortiePrep === false &&
|
|
midBriefingState?.sortiePrepStep === 'briefing' &&
|
|
midBriefingState?.sortiePrimaryAction?.kind === 'next' &&
|
|
midBriefingState?.sortiePrimaryAction?.nextStep === 'formation',
|
|
`Expected a mid-campaign battle sortie to open at the staged briefing: ${JSON.stringify(midBriefingState)}`
|
|
);
|
|
assert(
|
|
midBriefingState?.sortiePortraitRosterView?.enabled === true &&
|
|
midBriefingState?.sortiePortraitRosterView?.total > 8 &&
|
|
midBriefingState?.sortiePortraitRosterView?.visibleCount === 8 &&
|
|
midBriefingState?.sortiePortraitRosterView?.start === 1 &&
|
|
midBriefingState?.sortiePortraitRosterView?.end === 8,
|
|
`Expected the mid-campaign staged formation to expose an eight-card portrait roster window: ${JSON.stringify(midBriefingState?.sortiePortraitRosterView)}`
|
|
);
|
|
|
|
await advanceSortiePrepStep(page, 'formation');
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'mid camp sortie formation');
|
|
|
|
const midFormationPortraitState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const missingMidFormationPortraits = midFormationPortraitState?.sortiePortraitRoster?.filter(
|
|
(unit) => !unit.portraitReady || !unit.portraitTextureKey?.startsWith('portrait-card-')
|
|
) ?? [];
|
|
assert(
|
|
midFormationPortraitState?.sortiePortraitRoster?.length === 23 && missingMidFormationPortraits.length === 0,
|
|
`Expected all 23 campaign officers to use loaded lightweight portrait cards: ${JSON.stringify(missingMidFormationPortraits)}`
|
|
);
|
|
|
|
await page.mouse.move(530, 260);
|
|
await page.waitForFunction(() => Boolean(window.__HEROS_DEBUG__?.camp()?.sortieHoveredUnitId), undefined, { timeout: 30000 });
|
|
const hoveredPortraitUnitId = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieHoveredUnitId);
|
|
assert(
|
|
midFormationPortraitState.sortiePortraitRoster.some(
|
|
(unit) => unit.id === hoveredPortraitUnitId && unit.portraitReady && unit.portraitTextureKey?.startsWith('portrait-card-')
|
|
),
|
|
`Expected portrait-card hover feedback for a mapped officer: ${JSON.stringify(hoveredPortraitUnitId)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation-hover.png`, fullPage: true });
|
|
await page.mouse.move(40, 710);
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieHoveredUnitId === null, undefined, { timeout: 30000 });
|
|
|
|
const midFormationFirstPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
|
|
await page.mouse.click(658, 196);
|
|
await page.waitForFunction((previousScroll) => {
|
|
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
|
|
return view?.scroll > previousScroll;
|
|
}, midFormationFirstPage?.scroll ?? 0, { timeout: 30000 });
|
|
const midFormationSecondPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
|
|
assert(
|
|
midFormationSecondPage?.start > 1 && midFormationSecondPage?.end > 8,
|
|
`Expected the portrait roster next-page control to reveal later officers: ${JSON.stringify(midFormationSecondPage)}`
|
|
);
|
|
await page.mouse.click(610, 196);
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView?.scroll === 0, undefined, { timeout: 30000 });
|
|
|
|
const fullRosterCandidateProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const candidate = state?.sortieRoster?.find((unit) => !unit.selected && unit.available);
|
|
if (!candidate || typeof scene?.toggleSortieUnit !== 'function') {
|
|
return { candidateId: null, selectedBefore: state?.selectedSortieUnitIds ?? [] };
|
|
}
|
|
scene.toggleSortieUnit(candidate.id);
|
|
return { candidateId: candidate.id, selectedBefore: state.selectedSortieUnitIds ?? [] };
|
|
});
|
|
assert(fullRosterCandidateProbe.candidateId, `Expected a full-roster swap candidate: ${JSON.stringify(fullRosterCandidateProbe)}`);
|
|
await page.waitForFunction((probe) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortieFocusedUnitId === probe.candidateId &&
|
|
state?.sortiePrepStep === 'formation' &&
|
|
state?.sortieFocusedSynergyPreview?.mode === 'waiting' &&
|
|
state?.selectedSortieUnitIds?.length === probe.selectedBefore.length &&
|
|
!state.selectedSortieUnitIds.includes(probe.candidateId);
|
|
}, fullRosterCandidateProbe, { timeout: 30000 });
|
|
|
|
const underCapacityProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const removable = state?.sortieRoster?.find((unit) => unit.selected && !unit.required);
|
|
if (!removable || typeof scene?.toggleSortieUnit !== 'function') {
|
|
return { removedUnitId: null, selectedBefore: state?.selectedSortieUnitIds ?? [] };
|
|
}
|
|
scene.toggleSortieUnit(removable.id);
|
|
return { removedUnitId: removable.id, selectedBefore: state.selectedSortieUnitIds ?? [] };
|
|
});
|
|
assert(underCapacityProbe.removedUnitId, `Expected a removable mid-campaign officer: ${JSON.stringify(underCapacityProbe)}`);
|
|
await page.waitForFunction((probe) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortieVisible === true &&
|
|
state?.sortiePrepStep === 'formation' &&
|
|
state?.selectedSortieUnitIds?.length === probe.selectedBefore.length - 1 &&
|
|
!state.selectedSortieUnitIds.includes(probe.removedUnitId) &&
|
|
state?.sortieFocusedSynergyPreview?.mode === 'add';
|
|
}, underCapacityProbe, { timeout: 30000 });
|
|
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await page.mouse.click(962, 310);
|
|
await waitForCamp(page);
|
|
const restoredUnderCapacityState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
restoredUnderCapacityState?.selectedSortieUnitIds?.length === underCapacityProbe.selectedBefore.length - 1 &&
|
|
!restoredUnderCapacityState.selectedSortieUnitIds.includes(underCapacityProbe.removedUnitId),
|
|
`Expected an under-capacity manual formation to survive scene recreation: ${JSON.stringify(restoredUnderCapacityState?.selectedSortieUnitIds)} / ${JSON.stringify(underCapacityProbe)}`
|
|
);
|
|
|
|
const reserveDoctrineSetup = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const supportUnitIds = (state?.sortieRoster ?? [])
|
|
.filter((unit) => unit.selected && unit.formationRole === 'support')
|
|
.map((unit) => unit.id);
|
|
scene.sortieFormationAssignments = {
|
|
...scene.sortieFormationAssignments,
|
|
...Object.fromEntries(supportUnitIds.map((unitId) => [unitId, 'reserve']))
|
|
};
|
|
scene.persistSortieSelection();
|
|
return { supportUnitIds, state: window.__HEROS_DEBUG__?.camp() };
|
|
});
|
|
assert(
|
|
reserveDoctrineSetup.supportUnitIds.length > 0 && reserveDoctrineSetup.state?.sortiePlan?.formationCoverageComplete === false,
|
|
`Expected the mid-campaign doctrine setup to leave support uncovered with reserve officers: ${JSON.stringify(reserveDoctrineSetup)}`
|
|
);
|
|
|
|
const midCampSelectedSortieUnitIds = reserveDoctrineSetup.state?.selectedSortieUnitIds ?? [];
|
|
const midCampDeploymentPreview = reserveDoctrineSetup.state?.sortieDeploymentPreview ?? [];
|
|
const midCampFormationAssignments = reserveDoctrineSetup.state?.sortieFormationAssignments ?? {};
|
|
const midCampItemAssignments = reserveDoctrineSetup.state?.sortieItemAssignments ?? {};
|
|
await page.mouse.click(1160, 38);
|
|
await waitForSortiePrep(page);
|
|
const restoredMidBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
restoredMidBriefingState?.stagedSortiePrep === true && restoredMidBriefingState?.sortiePrepStep === 'briefing',
|
|
`Expected reopening the mid-campaign sortie to restart at briefing: ${JSON.stringify(restoredMidBriefingState)}`
|
|
);
|
|
const restoredMidFormationState = await advanceSortiePrepStep(page, 'formation');
|
|
assert(
|
|
restoredMidFormationState?.sortiePlan?.formationCoverageComplete === false &&
|
|
JSON.stringify(restoredMidFormationState?.sortieFormationAssignments) === JSON.stringify(midCampFormationAssignments) &&
|
|
JSON.stringify(restoredMidFormationState?.sortieItemAssignments) === JSON.stringify(midCampItemAssignments),
|
|
`Expected the staged formation screen to preserve role and supply assignments: ${JSON.stringify(restoredMidFormationState)}`
|
|
);
|
|
const restoredMidLoadoutState = await advanceSortiePrepStep(page, 'loadout');
|
|
assertSameMembers(
|
|
restoredMidLoadoutState?.selectedSortieUnitIds,
|
|
midCampSelectedSortieUnitIds,
|
|
`Expected briefing, formation, and loadout steps to preserve the selected mid-campaign roster: ${JSON.stringify(restoredMidLoadoutState?.selectedSortieUnitIds)}`
|
|
);
|
|
assert(
|
|
JSON.stringify(restoredMidLoadoutState?.sortieFormationAssignments) === JSON.stringify(midCampFormationAssignments) &&
|
|
JSON.stringify(restoredMidLoadoutState?.sortieItemAssignments) === JSON.stringify(midCampItemAssignments),
|
|
`Expected loadout navigation to preserve role and supply assignments: ${JSON.stringify(restoredMidLoadoutState)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-loadout.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'mid camp sortie loadout');
|
|
await page.mouse.click(1116, 656);
|
|
await advanceUntilBattle(page, midCampNextBattleId);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-next-battle.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'mid camp next battle');
|
|
|
|
const midNextBattleProbe = await readBattleDoctrineProbe(page);
|
|
assert(midNextBattleProbe?.battleId === midCampNextBattleId, `Expected mid-campaign sortie to enter next battle: ${JSON.stringify(midNextBattleProbe)} / ${midCampNextBattleId}`);
|
|
assertSameMembers(
|
|
midNextBattleProbe?.selectedSortieUnitIds,
|
|
midCampSelectedSortieUnitIds,
|
|
`Expected mid-campaign next battle to receive selected sortie ids: ${JSON.stringify(midNextBattleProbe?.selectedSortieUnitIds)} / ${JSON.stringify(midCampSelectedSortieUnitIds)}`
|
|
);
|
|
assertSameMembers(
|
|
midNextBattleProbe?.deployedAllyIds,
|
|
midCampSelectedSortieUnitIds,
|
|
`Expected mid-campaign next battle deployed allies to match sortie ids: ${JSON.stringify(midNextBattleProbe?.deployedAllyIds)} / ${JSON.stringify(midCampSelectedSortieUnitIds)}`
|
|
);
|
|
assertDeploymentMatchesPreview(
|
|
midNextBattleProbe?.deployedAllyPositions,
|
|
midCampDeploymentPreview,
|
|
`Expected mid-campaign next battle ally positions to match deployment preview: ${JSON.stringify(midNextBattleProbe?.deployedAllyPositions)} / ${JSON.stringify(midCampDeploymentPreview)}`
|
|
);
|
|
assertSortieDoctrine(midNextBattleProbe, { requireReserve: true });
|
|
assert(
|
|
midNextBattleProbe.sortieDoctrine?.coveredRoleCount < 3,
|
|
`Expected the seeded mid-campaign formation to allow a missing doctrine role: ${JSON.stringify(midNextBattleProbe.sortieDoctrine)}`
|
|
);
|
|
assert(
|
|
midNextBattleProbe.firstSortieRoleEffects?.active === false &&
|
|
midNextBattleProbe.firstSortieRoleEffects?.trinityActive === false,
|
|
`Expected Peach Garden trinity to remain exclusive to the second battle: ${JSON.stringify(midNextBattleProbe.firstSortieRoleEffects)}`
|
|
);
|
|
assert(
|
|
midNextBattleProbe.units.some((unit) =>
|
|
unit.faction === 'ally' &&
|
|
!['liu-bei', 'guan-yu', 'zhang-fei'].includes(unit.id) &&
|
|
['front', 'flank', 'support'].includes(unit.formationRole) &&
|
|
unit.sortieRoleEffect
|
|
),
|
|
`Expected a non-Peach-Garden officer to receive a generic sortie role effect: ${JSON.stringify(midNextBattleProbe.units)}`
|
|
);
|
|
|
|
await seedCampaignSave(page, {
|
|
battleId: 'sixty-sixth-battle-wuzhang-final',
|
|
battleTitle: '오장원 최종전',
|
|
step: 'sixty-sixth-camp',
|
|
gold: 9800,
|
|
turnNumber: 18,
|
|
defeatedEnemies: 20,
|
|
selectedSortieUnitIds: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'ma-dai', 'huang-quan', 'li-yan', 'wei-yan']
|
|
});
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await page.mouse.click(962, 310);
|
|
await waitForCamp(page);
|
|
const finalCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(finalCampState?.campaign?.step === 'sixty-sixth-camp', `Expected final camp save to continue: ${JSON.stringify(finalCampState)}`);
|
|
assert(finalCampState.sortieHasBattle === false, `Expected final camp to expose no playable sortie target: ${JSON.stringify(finalCampState)}`);
|
|
assert(finalCampState.stagedSortiePrep === false, `Expected the non-battle final camp to avoid the three-stage sortie flow: ${JSON.stringify(finalCampState)}`);
|
|
assert(finalCampState.nextSortieBattleId === null, `Expected final camp to route to ending instead of a battle: ${JSON.stringify(finalCampState)}`);
|
|
assert(
|
|
Array.isArray(finalCampState.sortieDeploymentPreview) && finalCampState.sortieDeploymentPreview.length === 0,
|
|
`Expected final camp deployment preview to stay empty: ${JSON.stringify(finalCampState?.sortieDeploymentPreview)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-final-camp.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'final camp');
|
|
|
|
await page.mouse.click(1160, 38);
|
|
const finalTransitionScenes = await waitForStoryOrEnding(page);
|
|
assert(!finalTransitionScenes.includes('BattleScene'), `Expected final camp transition to avoid BattleScene: ${JSON.stringify(finalTransitionScenes)}`);
|
|
if (finalTransitionScenes.includes('StoryScene')) {
|
|
await waitForStoryReady(page);
|
|
await advanceStoryUntilEnding(page);
|
|
}
|
|
await waitForEnding(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-ending.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'ending');
|
|
|
|
const endingState = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('EndingScene')?.getDebugState?.());
|
|
assert(endingState?.campaignStep === 'ending-complete', `Expected ending-complete state: ${JSON.stringify(endingState)}`);
|
|
assert(endingState.latestBattleId === 'sixty-sixth-battle-wuzhang-final', `Expected Wuzhang final as latest battle: ${JSON.stringify(endingState)}`);
|
|
|
|
await page.keyboard.press('Enter');
|
|
await waitForTitle(page);
|
|
await page.mouse.click(962, 310);
|
|
await waitForEnding(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-ending-continue.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'ending continue');
|
|
|
|
const endingContinueState = await page.evaluate(() => ({
|
|
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
|
|
ending: window.__HEROS_DEBUG__?.scene('EndingScene')?.getDebugState?.()
|
|
}));
|
|
assert(
|
|
endingContinueState.activeScenes.includes('EndingScene') && endingContinueState.ending?.campaignStep === 'ending-complete',
|
|
`Expected title continue to reopen ending: ${JSON.stringify(endingContinueState)}`
|
|
);
|
|
|
|
const relevantLogs = consoleMessages.filter(
|
|
(message) => message.type === 'error' || (isWarning(message.type) && relevantLogPattern.test(message.text))
|
|
);
|
|
const blockingRequestFailures = requestFailures.filter((request) => request.failure !== 'net::ERR_ABORTED');
|
|
assert(relevantLogs.length === 0, `Unexpected browser console issues: ${JSON.stringify(relevantLogs)}`);
|
|
assert(pageErrors.length === 0, `Unexpected browser runtime errors: ${JSON.stringify(pageErrors)}`);
|
|
assert(blockingRequestFailures.length === 0, `Unexpected browser request failures: ${JSON.stringify(blockingRequestFailures)}`);
|
|
|
|
console.log(`Verified release-candidate user flow at ${targetUrl}`);
|
|
} finally {
|
|
await browser?.close();
|
|
if (serverProcess && !serverProcess.killed) {
|
|
serverProcess.kill();
|
|
}
|
|
}
|
|
|
|
function withDebugParam(url) {
|
|
const parsed = new URL(url);
|
|
parsed.searchParams.set('debug', '1');
|
|
parsed.searchParams.set('renderer', 'canvas');
|
|
return parsed.toString();
|
|
}
|
|
|
|
function isWarning(type) {
|
|
return type === 'warning' || type === 'warn';
|
|
}
|
|
|
|
async function waitForTitle(page) {
|
|
await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 });
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 });
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
return activeScenes.includes('TitleScene');
|
|
}, undefined, { timeout: 90000 });
|
|
}
|
|
|
|
async function assertReleaseShellReady(page) {
|
|
const state = await page.evaluate(() => ({
|
|
title: document.title,
|
|
canvasCount: document.querySelectorAll('canvas').length,
|
|
debugApiPresent: Boolean(window.__HEROS_DEBUG__),
|
|
gameApiPresent: Boolean(window.__HEROS_GAME__)
|
|
}));
|
|
|
|
assert(state.title === expectedTitle, `Expected release page title "${expectedTitle}": ${JSON.stringify(state)}`);
|
|
assert(state.canvasCount === 1, `Expected exactly one release game canvas: ${JSON.stringify(state)}`);
|
|
assert(state.debugApiPresent === true, `Expected release QA debug API to be enabled: ${JSON.stringify(state)}`);
|
|
assert(state.gameApiPresent === true, `Expected release QA game handle to be enabled: ${JSON.stringify(state)}`);
|
|
}
|
|
|
|
async function waitForStoryReady(page) {
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.();
|
|
return activeScenes.includes('StoryScene') && story?.ready === true;
|
|
}, undefined, { timeout: 90000 });
|
|
}
|
|
|
|
async function waitForStoryOrEnding(page) {
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
return activeScenes.includes('StoryScene') || activeScenes.includes('EndingScene');
|
|
}, undefined, { timeout: 30000 });
|
|
return page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
|
|
}
|
|
|
|
async function advanceUntilBattle(page, battleId) {
|
|
for (let i = 0; i < 48; i += 1) {
|
|
const ready = await page.evaluate((expectedBattleId) => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
state?.scene === 'BattleScene' &&
|
|
state?.battleId === expectedBattleId &&
|
|
(state?.phase === 'deployment' || state?.phase === 'idle') &&
|
|
state?.mapBackgroundReady === true
|
|
);
|
|
}, battleId);
|
|
|
|
if (ready) {
|
|
await startDeploymentIfNeeded(page, battleId);
|
|
return;
|
|
}
|
|
|
|
await page.keyboard.press('Space');
|
|
await page.waitForTimeout(220);
|
|
}
|
|
|
|
await waitForBattleReady(page, battleId);
|
|
}
|
|
|
|
async function waitForBattleReady(page, battleId) {
|
|
await page.waitForFunction((expectedBattleId) => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
state?.scene === 'BattleScene' &&
|
|
state?.battleId === expectedBattleId &&
|
|
(state?.phase === 'deployment' || state?.phase === 'idle') &&
|
|
state?.mapBackgroundReady === true
|
|
);
|
|
}, battleId, { timeout: 90000 });
|
|
await startDeploymentIfNeeded(page, battleId);
|
|
}
|
|
|
|
async function startDeploymentIfNeeded(page, battleId) {
|
|
const state = await page.evaluate((expectedBattleId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return battle?.battleId === expectedBattleId ? battle : undefined;
|
|
}, battleId);
|
|
if (state?.phase !== 'deployment') {
|
|
return;
|
|
}
|
|
|
|
assert(state.deployedAllyIds?.length >= 3, `Expected deployment to show the selected ally formation: ${JSON.stringify(state)}`);
|
|
await page.mouse.click(1085, 637);
|
|
await page.waitForFunction((expectedBattleId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return battle?.battleId === expectedBattleId && battle?.phase === 'idle' && battle?.triggeredBattleEvents?.includes('opening');
|
|
}, battleId, { timeout: 30000 });
|
|
}
|
|
|
|
async function waitForBattleOutcome(page, outcome) {
|
|
await page.waitForFunction((expectedOutcome) => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return state?.battleOutcome === expectedOutcome && state?.phase === 'resolved' && state?.resultVisible === true;
|
|
}, outcome, { timeout: 90000 });
|
|
}
|
|
|
|
async function waitForCamp(page) {
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
const scene = window.__HEROS_DEBUG__?.scene('CampScene');
|
|
return (
|
|
activeScenes.includes('CampScene') &&
|
|
window.__HEROS_DEBUG__?.camp()?.scene === 'CampScene' &&
|
|
(scene?.contentObjects?.length ?? 0) > 0
|
|
);
|
|
}, undefined, { timeout: 90000 });
|
|
}
|
|
|
|
async function waitForSortiePrep(page) {
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
const camp = window.__HEROS_DEBUG__?.camp?.();
|
|
return activeScenes.includes('CampScene') && camp?.sortieVisible === true;
|
|
}, undefined, { timeout: 30000 });
|
|
}
|
|
|
|
async function advanceSortiePrepStep(page, expectedStep) {
|
|
await page.mouse.click(1116, 656);
|
|
await page.waitForFunction((step) => {
|
|
const camp = window.__HEROS_DEBUG__?.camp?.();
|
|
return camp?.sortieVisible === true && camp?.sortiePrepStep === step;
|
|
}, expectedStep, { timeout: 30000 });
|
|
await page.waitForTimeout(1000);
|
|
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.());
|
|
const expectedNextStep = expectedStep === 'formation' ? 'loadout' : null;
|
|
assert(
|
|
state?.stagedSortiePrep === true &&
|
|
state?.sortiePrepSteps?.filter((step) => step.active).length === 1 &&
|
|
state?.sortiePrepSteps?.some((step) => step.id === expectedStep && step.active) &&
|
|
state?.sortiePrimaryAction?.kind === (expectedNextStep ? 'next' : 'launch') &&
|
|
state?.sortiePrimaryAction?.nextStep === expectedNextStep,
|
|
`Expected staged sortie navigation to reach ${expectedStep}: ${JSON.stringify(state)}`
|
|
);
|
|
return state;
|
|
}
|
|
|
|
async function waitForCampAfterBattleResult(page) {
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
return activeScenes.includes('CampScene') || activeScenes.includes('StoryScene');
|
|
}, undefined, { timeout: 90000 });
|
|
|
|
for (let i = 0; i < 48; i += 1) {
|
|
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
|
|
if (activeScenes.includes('CampScene')) {
|
|
await waitForCamp(page);
|
|
return;
|
|
}
|
|
await page.keyboard.press('Space');
|
|
await page.waitForTimeout(240);
|
|
}
|
|
|
|
await waitForCamp(page);
|
|
}
|
|
|
|
async function advanceStoryUntilEnding(page) {
|
|
for (let i = 0; i < 48; i += 1) {
|
|
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
|
|
if (activeScenes.includes('EndingScene')) {
|
|
return;
|
|
}
|
|
|
|
await page.keyboard.press('Space');
|
|
await page.waitForTimeout(240);
|
|
}
|
|
}
|
|
|
|
async function waitForEnding(page) {
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
const ending = window.__HEROS_DEBUG__?.scene('EndingScene')?.getDebugState?.();
|
|
return activeScenes.includes('EndingScene') && ending?.ready === true && ending?.campaignStep === 'ending-complete';
|
|
}, undefined, { timeout: 90000 });
|
|
}
|
|
|
|
async function assertCanvasPainted(page, context) {
|
|
const probe = await page.evaluate(() => {
|
|
const canvas = document.querySelector('canvas');
|
|
if (!canvas) {
|
|
return { readable: false, missing: true, sampleCount: 0, nonTransparentRatio: 0, distinctColorBuckets: 0 };
|
|
}
|
|
|
|
const canvasContext = canvas.getContext('2d', { willReadFrequently: true });
|
|
if (!canvasContext) {
|
|
return { readable: false, missing: false, sampleCount: 0, nonTransparentRatio: 0, distinctColorBuckets: 0 };
|
|
}
|
|
|
|
const stepX = Math.max(1, Math.floor(canvas.width / 64));
|
|
const stepY = Math.max(1, Math.floor(canvas.height / 64));
|
|
const colorBuckets = new Set();
|
|
let sampleCount = 0;
|
|
let nonTransparentCount = 0;
|
|
|
|
for (let y = Math.floor(stepY / 2); y < canvas.height; y += stepY) {
|
|
for (let x = Math.floor(stepX / 2); x < canvas.width; x += stepX) {
|
|
const [r, g, b, a] = canvasContext.getImageData(x, y, 1, 1).data;
|
|
sampleCount += 1;
|
|
if (a > 8) {
|
|
nonTransparentCount += 1;
|
|
}
|
|
colorBuckets.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 5}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
readable: true,
|
|
missing: false,
|
|
sampleCount,
|
|
nonTransparentRatio: sampleCount > 0 ? nonTransparentCount / sampleCount : 0,
|
|
distinctColorBuckets: colorBuckets.size
|
|
};
|
|
});
|
|
|
|
assert(probe.readable === true, `Expected readable canvas pixels during ${context}: ${JSON.stringify(probe)}`);
|
|
assert(probe.sampleCount >= 500, `Expected enough canvas pixel samples during ${context}: ${JSON.stringify(probe)}`);
|
|
assert(probe.nonTransparentRatio > 0.5, `Expected non-empty canvas pixels during ${context}: ${JSON.stringify(probe)}`);
|
|
assert(probe.distinctColorBuckets >= 12, `Expected visually varied canvas pixels during ${context}: ${JSON.stringify(probe)}`);
|
|
}
|
|
|
|
async function readCampaignSave(page) {
|
|
return page.evaluate(() => {
|
|
const read = (key) => {
|
|
const raw = window.localStorage.getItem(key);
|
|
return raw ? JSON.parse(raw) : undefined;
|
|
};
|
|
return {
|
|
current: read('heros-web:campaign-state'),
|
|
slot1: read('heros-web:campaign-state:slot-1')
|
|
};
|
|
});
|
|
}
|
|
|
|
async function readBattleDoctrineProbe(page) {
|
|
return page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const roleSeals = (state?.units ?? [])
|
|
.filter((unit) => unit.faction === 'ally')
|
|
.map((unit) => {
|
|
const seal = scene?.unitViews?.get(unit.id)?.roleSeal;
|
|
return {
|
|
unitId: unit.id,
|
|
text: seal?.text ?? null,
|
|
visible: seal?.visible ?? false
|
|
};
|
|
});
|
|
const openingBannerTexts = (scene?.battleEventObjects ?? [])
|
|
.filter((object) => object?.type === 'Text')
|
|
.map((object) => object.text);
|
|
return { ...state, roleSeals, openingBannerTexts };
|
|
});
|
|
}
|
|
|
|
async function seedCampaignSave(page, options) {
|
|
await page.evaluate((seed) => {
|
|
const storageKey = 'heros-web:campaign-state';
|
|
const current = JSON.parse(window.localStorage.getItem(storageKey) ?? 'null');
|
|
if (!current?.firstBattleReport) {
|
|
throw new Error('Expected an existing campaign report before seeding RC save.');
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const objectives = [
|
|
{ id: 'leader', label: '지휘관 격파', achieved: true, status: 'done', detail: '전장 목표 완료', rewardGold: 420 },
|
|
{ id: 'survive', label: '본대 보존', achieved: true, status: 'done', detail: '핵심 장수 생존', rewardGold: 260 },
|
|
{ id: 'supply', label: '보급선 유지', achieved: true, status: 'done', detail: '수레와 군량 확보', rewardGold: 220 },
|
|
{ id: 'quick', label: '빠른 정리', achieved: false, status: 'failed', detail: `${seed.turnNumber}/12턴`, rewardGold: 180 }
|
|
];
|
|
const units = current.roster?.length ? current.roster : current.firstBattleReport.units;
|
|
const bonds = current.bonds?.length ? current.bonds : current.firstBattleReport.bonds;
|
|
const report = {
|
|
...current.firstBattleReport,
|
|
battleId: seed.battleId,
|
|
battleTitle: seed.battleTitle,
|
|
outcome: 'victory',
|
|
turnNumber: seed.turnNumber,
|
|
rewardGold: 1080,
|
|
defeatedEnemies: seed.defeatedEnemies,
|
|
totalEnemies: seed.defeatedEnemies,
|
|
objectives,
|
|
units,
|
|
bonds,
|
|
mvp: current.firstBattleReport.mvp ?? { unitId: 'zhuge-liang', name: '제갈량', damageDealt: 320, defeats: 2 },
|
|
itemRewards: ['상처약 2', '탁주 1'],
|
|
completedCampDialogues: current.completedCampDialogues ?? [],
|
|
completedCampVisits: current.completedCampVisits ?? [],
|
|
createdAt: now
|
|
};
|
|
const settlement = {
|
|
battleId: report.battleId,
|
|
battleTitle: report.battleTitle,
|
|
outcome: 'victory',
|
|
rewardGold: report.rewardGold,
|
|
itemRewards: report.itemRewards,
|
|
objectives,
|
|
units: units.map((unit) => ({
|
|
unitId: unit.id,
|
|
name: unit.name,
|
|
level: unit.level ?? 1,
|
|
exp: unit.exp ?? 0,
|
|
hp: unit.hp ?? unit.maxHp ?? 1,
|
|
maxHp: unit.maxHp ?? 1,
|
|
equipment: unit.equipment
|
|
})),
|
|
bonds: bonds.map((bond) => ({
|
|
id: bond.id,
|
|
title: bond.title,
|
|
level: bond.level ?? 1,
|
|
exp: bond.exp ?? 0,
|
|
battleExp: bond.battleExp ?? 0
|
|
})),
|
|
reserveTraining: [],
|
|
completedAt: now
|
|
};
|
|
const next = {
|
|
...current,
|
|
updatedAt: now,
|
|
step: seed.step,
|
|
activeSaveSlot: 1,
|
|
gold: seed.gold,
|
|
inventory: { ...(current.inventory ?? {}), 콩: 12, 상처약: 6, 탁주: 4 },
|
|
selectedSortieUnitIds: seed.selectedSortieUnitIds,
|
|
latestBattleId: seed.battleId,
|
|
firstBattleReport: report,
|
|
battleHistory: {
|
|
...(current.battleHistory ?? {}),
|
|
[seed.battleId]: settlement
|
|
}
|
|
};
|
|
|
|
window.localStorage.setItem(storageKey, JSON.stringify(next));
|
|
window.localStorage.setItem(`${storageKey}:slot-1`, JSON.stringify(next));
|
|
}, options);
|
|
}
|
|
|
|
async function ensurePreviewServer(url) {
|
|
if (await canReach(url)) {
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = new URL(url);
|
|
const isLocal = ['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname);
|
|
if (!isLocal) {
|
|
throw new Error(`No server responded at ${url}`);
|
|
}
|
|
|
|
const stderr = [];
|
|
const child = spawn(process.execPath, ['node_modules/vite/bin/vite.js', 'preview', '--host', '127.0.0.1', '--port', parsed.port || '4173'], {
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
});
|
|
|
|
child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
|
|
child.stdout.on('data', () => {});
|
|
|
|
for (let i = 0; i < 120; i += 1) {
|
|
if (await canReach(url)) {
|
|
return child;
|
|
}
|
|
await delay(250);
|
|
}
|
|
|
|
child.kill();
|
|
throw new Error(`Vite preview did not start at ${url}\n${stderr.join('')}`);
|
|
}
|
|
|
|
async function canReach(url) {
|
|
try {
|
|
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
function assertUnique(values, message) {
|
|
assert(Array.isArray(values) && values.length > 0, message);
|
|
assert(values.every(Boolean), message);
|
|
assert(new Set(values).size === values.length, message);
|
|
}
|
|
|
|
function assertSameMembers(values, expected, message) {
|
|
assert(Array.isArray(values) && Array.isArray(expected), message);
|
|
assert(values.length === expected.length, message);
|
|
const valueSet = new Set(values);
|
|
assert(expected.every((value) => valueSet.has(value)), message);
|
|
}
|
|
|
|
function assertDeploymentMatchesPreview(positions, preview, message) {
|
|
assert(Array.isArray(positions) && Array.isArray(preview), message);
|
|
assert(positions.length === preview.length, message);
|
|
const positionsById = new Map(positions.map((position) => [position.id, position]));
|
|
assert(
|
|
preview.every((slot) => {
|
|
const position = positionsById.get(slot.unitId);
|
|
return position?.x === slot.x && position?.y === slot.y;
|
|
}),
|
|
message
|
|
);
|
|
}
|
|
|
|
function assertSortieDoctrine(probe, options = {}) {
|
|
const doctrine = probe?.sortieDoctrine;
|
|
const expectedSealByRole = { front: '전', flank: '돌', support: '후' };
|
|
const expectedRoles = Object.keys(expectedSealByRole);
|
|
const roles = doctrine?.roles ?? [];
|
|
const roleById = new Map(roles.map((role) => [role.role, role]));
|
|
const allyUnits = (probe?.units ?? []).filter((unit) => unit.faction === 'ally');
|
|
const sealsByUnitId = new Map((probe?.roleSeals ?? []).map((seal) => [seal.unitId, seal]));
|
|
const effectUnits = allyUnits.filter((unit) => expectedRoles.includes(unit.formationRole));
|
|
const reserveUnits = allyUnits.filter((unit) => unit.formationRole === 'reserve');
|
|
const coveredRoleCount = roles.filter((role) => role.unitIds.length > 0).length;
|
|
|
|
assert(doctrine?.active === true, `Expected sortie doctrine to be active: ${JSON.stringify(probe)}`);
|
|
assert(doctrine?.openingBannerShown === true, `Expected sortie doctrine opening banner to be shown: ${JSON.stringify(doctrine)}`);
|
|
assert(
|
|
roles.length === expectedRoles.length && expectedRoles.every((role) => roleById.has(role)),
|
|
`Expected doctrine debug state to expose front, flank, and support roles: ${JSON.stringify(doctrine)}`
|
|
);
|
|
assert(
|
|
doctrine.coveredRoleCount === coveredRoleCount,
|
|
`Expected doctrine role coverage to match non-empty role groups: ${JSON.stringify(doctrine)}`
|
|
);
|
|
if (options.requireFullCoverage) {
|
|
assert(coveredRoleCount === 3, `Expected all three doctrine roles to be covered: ${JSON.stringify(doctrine)}`);
|
|
}
|
|
|
|
assert(effectUnits.length > 0, `Expected at least one deployed officer with a doctrine role effect: ${JSON.stringify(allyUnits)}`);
|
|
effectUnits.forEach((unit) => {
|
|
const role = roleById.get(unit.formationRole);
|
|
const seal = sealsByUnitId.get(unit.id);
|
|
assert(
|
|
role?.unitIds.includes(unit.id) &&
|
|
unit.sortieRoleEffect?.label === role.label &&
|
|
unit.sortieRoleEffect?.summary === role.summary,
|
|
`Expected ${unit.id} to expose the assigned ${unit.formationRole} doctrine effect: ${JSON.stringify({ unit, role })}`
|
|
);
|
|
assert(
|
|
seal?.text === expectedSealByRole[unit.formationRole] && unit.roleSealVisible === seal.visible,
|
|
`Expected ${unit.id} to show the ${expectedSealByRole[unit.formationRole]} role seal: ${JSON.stringify({ unit, seal })}`
|
|
);
|
|
if (options.requireVisibleSeals) {
|
|
assert(
|
|
unit.roleSealVisible === true && seal.visible === true,
|
|
`Expected ${unit.id} role seal to be visible in the opening formation: ${JSON.stringify({ unit, seal })}`
|
|
);
|
|
}
|
|
});
|
|
|
|
if (options.requireReserve) {
|
|
assert(reserveUnits.length > 0, `Expected the doctrine probe to include a reserve officer: ${JSON.stringify(allyUnits)}`);
|
|
}
|
|
reserveUnits.forEach((unit) => {
|
|
const seal = sealsByUnitId.get(unit.id);
|
|
assert(
|
|
unit.sortieRoleEffect === null &&
|
|
unit.roleSealVisible === false &&
|
|
seal?.visible === false &&
|
|
seal?.text === null &&
|
|
roles.every((role) => !role.unitIds.includes(unit.id)),
|
|
`Expected reserve officer ${unit.id} to have no doctrine effect or role seal: ${JSON.stringify({ unit, seal, doctrine })}`
|
|
);
|
|
});
|
|
}
|
|
|
|
function runCommand(command, args, env = {}) {
|
|
const result = spawnSync(command, args, { cwd: process.cwd(), env: { ...process.env, ...env }, stdio: 'inherit' });
|
|
if (result.status !== 0) {
|
|
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
|
|
}
|
|
}
|