diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 7ee0d98..2f39bca 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -38,6 +38,7 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(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)}`); @@ -45,8 +46,10 @@ try { 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'); @@ -76,6 +79,7 @@ try { 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'); @@ -97,6 +101,7 @@ try { 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'); @@ -128,6 +133,7 @@ try { 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)}`); @@ -150,6 +156,7 @@ try { 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)}`); 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); @@ -159,6 +166,7 @@ try { } 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)}`); @@ -169,6 +177,7 @@ try { 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() ?? [], @@ -337,6 +346,50 @@ async function waitForEnding(page) { }, 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) => {