feat: paginate large camp rosters

This commit is contained in:
2026-07-12 03:12:50 +09:00
parent 1155a11b22
commit ea60aa1de6
2 changed files with 586 additions and 19 deletions

View File

@@ -1,5 +1,5 @@
import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import { mkdirSync, writeFileSync } from 'node:fs';
import { chromium } from 'playwright';
const targetUrl = withDebugParam(process.env.RELEASE_QA_URL ?? 'http://127.0.0.1:4173/');
@@ -2001,6 +2001,17 @@ try {
}
});
assert(firstCampProbe.state?.campaign?.step === 'first-camp', `Expected victory to return to first camp: ${JSON.stringify(firstCampProbe.state)}`);
assert(
firstCampProbe.state?.campRoster?.pageCount === 1 &&
firstCampProbe.state.campRoster.totalCount === 3 &&
firstCampProbe.state.campRoster.visibleUnitIds.length === 3 &&
firstCampProbe.state.campRoster.rowBounds.length === 3 &&
firstCampProbe.state.campRoster.rowBounds.every((row) => row.height >= 100) &&
firstCampProbe.state.campRoster.navigationBounds === null &&
firstCampProbe.state.campRoster.previousEnabled === false &&
firstCampProbe.state.campRoster.nextEnabled === false,
`Expected the three-officer first camp to retain spacious cards without redundant pagination: ${JSON.stringify(firstCampProbe.state?.campRoster)}`
);
assert(
firstCampProbe.state?.nextSortieBattleId === 'second-battle-yellow-turban-pursuit',
`Expected first camp sortie flow to prepare unlocked second battle: ${JSON.stringify(firstCampProbe.state)}`
@@ -4670,8 +4681,8 @@ try {
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 captureStableCampScreenshot(page, `${screenshotDir}/rc-final-camp.png`, 0, 'final camp');
await assertFinalCampRosterPagination(page);
await page.mouse.click(1160, 38);
const finalTransitionScenes = await waitForStoryOrEnding(page);
@@ -5389,6 +5400,404 @@ async function waitForCamp(page) {
}, undefined, { timeout: 90000 });
}
async function waitForCampVisualFrame(page, expectedPage) {
await page.waitForFunction(
(pageNumber) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === pageNumber,
expectedPage,
{ timeout: 15000 }
);
await page.evaluate(() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
await page.waitForFunction(() => {
const canvas = document.querySelector('canvas');
const context = canvas?.getContext('2d', { willReadFrequently: true });
if (!canvas || !context) {
return false;
}
const colorBuckets = new Set();
let litSamples = 0;
for (let y = 110; y <= 560; y += 30) {
for (let x = 400; x <= 1200; x += 30) {
const [r, g, b, a] = context.getImageData(x, y, 1, 1).data;
if (a > 8 && r + g + b > 54) {
litSamples += 1;
}
colorBuckets.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 5}`);
}
}
return litSamples >= 18 && colorBuckets.size >= 12;
}, undefined, { timeout: 15000 });
}
async function captureStableCampScreenshot(page, path, expectedPage, label) {
await waitForCampVisualFrame(page, expectedPage);
const capture = await page.evaluate(() => new Promise((resolve) => {
const game = window.__HEROS_GAME__;
const canvas = document.querySelector('canvas');
if (!game?.loop || !game.renderer || !canvas) {
resolve({ loopAvailable: false, dataUrl: null });
return;
}
game.renderer.once('postrender', () => {
game.loop.sleep();
resolve({ loopAvailable: true, dataUrl: canvas.toDataURL('image/png') });
});
}));
try {
assert(
capture.dataUrl?.startsWith('data:image/png;base64,'),
`Expected a complete canvas capture for ${label}: ${JSON.stringify({ loopAvailable: capture.loopAvailable, hasDataUrl: Boolean(capture.dataUrl) })}`
);
writeFileSync(path, Buffer.from(capture.dataUrl.split(',')[1], 'base64'));
await assertCanvasPainted(page, label);
} finally {
if (capture.loopAvailable) {
await page.evaluate(() => new Promise((resolve) => {
window.__HEROS_GAME__?.loop?.wake();
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
}
}
}
async function assertFinalCampRosterPagination(page) {
const initialState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const initialRoster = initialState?.campRoster;
const initialSelectedUnitId = initialState?.selectedUnitId ?? null;
const initialSave = await readCampaignSave(page);
const expectedUnitIds = (initialState?.campaign?.roster ?? []).map((unit) => unit.id);
assert(
initialRoster?.page === 0 &&
initialRoster.pageCount === 4 &&
initialRoster.rowsPerPage === 6 &&
initialRoster.totalCount === 23 &&
expectedUnitIds.length === 23 &&
new Set(expectedUnitIds).size === 23 &&
expectedUnitIds.includes(initialSelectedUnitId),
`Expected the final camp roster to open as four six-row pages for 23 unique officers: ${JSON.stringify({
campRoster: initialRoster,
expectedUnitIds
})}`
);
const visitedUnitIds = [];
let verificationError;
try {
for (let expectedPage = 0; expectedPage < initialRoster.pageCount; expectedPage += 1) {
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const roster = state?.campRoster;
assertCampRosterPageLayout(roster, expectedPage, initialRoster.pageCount, initialSelectedUnitId, state?.selectedUnitId);
const expectedVisibleCount = expectedPage === initialRoster.pageCount - 1
? initialRoster.totalCount - initialRoster.rowsPerPage * (initialRoster.pageCount - 1)
: initialRoster.rowsPerPage;
assert(
roster.visibleUnitIds.length === expectedVisibleCount &&
roster.rowBounds.length === expectedVisibleCount &&
sameJsonValue(roster.rowBounds.map((row) => row.unitId), roster.visibleUnitIds),
`Expected final camp roster page ${expectedPage + 1} to expose ${expectedVisibleCount} matching rows: ${JSON.stringify(roster)}`
);
roster.visibleUnitIds.forEach((unitId) => {
assert(
expectedUnitIds.includes(unitId) && !visitedUnitIds.includes(unitId),
`Expected each final camp officer to appear on exactly one roster page: ${JSON.stringify({
expectedPage,
unitId,
visitedUnitIds,
expectedUnitIds
})}`
);
visitedUnitIds.push(unitId);
});
if (expectedPage === 1) {
const selectionTargetUnitId = roster.visibleUnitIds[0];
assert(
selectionTargetUnitId && selectionTargetUnitId !== initialSelectedUnitId,
`Expected the second roster page to expose a selectable officer other than the initial detail: ${JSON.stringify(roster)}`
);
const selectionPoint = await readCampRosterRowPoint(page, selectionTargetUnitId);
await page.mouse.click(selectionPoint.x, selectionPoint.y);
await page.waitForFunction(
({ pageNumber, unitId }) => {
const state = window.__HEROS_DEBUG__?.camp();
return state?.campRoster?.page === pageNumber && state?.selectedUnitId === unitId;
},
{ pageNumber: expectedPage, unitId: selectionTargetUnitId },
{ timeout: 30000 }
);
const previousPoint = await readCampRosterNavigationPoint(page, 'previous');
await page.mouse.click(previousPoint.x, previousPoint.y);
await page.waitForFunction(
({ pageNumber, unitId }) => {
const state = window.__HEROS_DEBUG__?.camp();
return state?.campRoster?.page === pageNumber && state?.selectedUnitId === unitId;
},
{ pageNumber: 0, unitId: selectionTargetUnitId },
{ timeout: 30000 }
);
const restorePoint = await readCampRosterRowPoint(page, initialSelectedUnitId);
await page.mouse.click(restorePoint.x, restorePoint.y);
await page.waitForFunction(
(unitId) => window.__HEROS_DEBUG__?.camp()?.selectedUnitId === unitId,
initialSelectedUnitId,
{ timeout: 30000 }
);
const nextPoint = await readCampRosterNavigationPoint(page, 'next');
await page.mouse.click(nextPoint.x, nextPoint.y);
await page.waitForFunction(
({ pageNumber, unitId }) => {
const state = window.__HEROS_DEBUG__?.camp();
return state?.campRoster?.page === pageNumber && state?.selectedUnitId === unitId;
},
{ pageNumber: expectedPage, unitId: initialSelectedUnitId },
{ timeout: 30000 }
);
}
if (expectedPage === initialRoster.pageCount - 1) {
assert(
roster.visibleUnitIds.length === 5 && roster.previousEnabled === true && roster.nextEnabled === false,
`Expected the final roster page to contain five officers with only previous navigation enabled: ${JSON.stringify(roster)}`
);
await captureStableCampScreenshot(
page,
`${screenshotDir}/rc-final-camp-roster-last-page.png`,
expectedPage,
'final camp roster last page'
);
continue;
}
const nextPoint = await readCampRosterNavigationPoint(page, 'next');
await page.mouse.click(nextPoint.x, nextPoint.y);
await page.waitForFunction(
(pageNumber) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === pageNumber,
expectedPage + 1,
{ timeout: 30000 }
);
}
assert(
visitedUnitIds.length === 23 &&
new Set(visitedUnitIds).size === 23 &&
sameJsonValue([...visitedUnitIds].sort(), [...expectedUnitIds].sort()),
`Expected pagination to expose all 23 final-camp officers exactly once: ${JSON.stringify({
visitedUnitIds,
expectedUnitIds
})}`
);
for (let expectedPage = initialRoster.pageCount - 2; expectedPage >= 0; expectedPage -= 1) {
const previousPoint = await readCampRosterNavigationPoint(page, 'previous');
await page.mouse.click(previousPoint.x, previousPoint.y);
await page.waitForFunction(
({ pageNumber, selectedUnitId }) => {
const state = window.__HEROS_DEBUG__?.camp();
return state?.campRoster?.page === pageNumber && state?.selectedUnitId === selectedUnitId;
},
{ pageNumber: expectedPage, selectedUnitId: initialSelectedUnitId },
{ timeout: 30000 }
);
}
} catch (error) {
verificationError = error;
} finally {
const currentPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.campRoster?.page ?? 0);
for (let pageNumber = currentPage; pageNumber > 0; pageNumber -= 1) {
const previousPoint = await readCampRosterNavigationPoint(page, 'previous');
await page.mouse.click(previousPoint.x, previousPoint.y);
await page.waitForFunction(
(expectedPage) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === expectedPage,
pageNumber - 1,
{ timeout: 30000 }
);
}
}
if (verificationError) {
throw verificationError;
}
const restoredState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const restoredSave = await readCampaignSave(page);
assert(
restoredState?.campRoster?.page === initialRoster.page &&
restoredState?.selectedUnitId === initialSelectedUnitId &&
sameJsonValue(restoredSave, initialSave),
`Expected the final-camp roster fixture to restore its page, selected detail, and saves: ${JSON.stringify({
initialPage: initialRoster.page,
restoredPage: restoredState?.campRoster?.page,
initialSelectedUnitId,
restoredSelectedUnitId: restoredState?.selectedUnitId,
saveUnchanged: sameJsonValue(restoredSave, initialSave)
})}`
);
}
function assertCampRosterPageLayout(roster, expectedPage, pageCount, expectedSelectedUnitId, actualSelectedUnitId) {
assert(
roster?.page === expectedPage &&
roster.pageCount === pageCount &&
roster.rowsPerPage === 6 &&
roster.totalCount === 23 &&
actualSelectedUnitId === expectedSelectedUnitId,
`Expected stable final-camp roster state on page ${expectedPage + 1}: ${JSON.stringify({
roster,
expectedSelectedUnitId,
actualSelectedUnitId
})}`
);
assert(
roster.previousEnabled === (expectedPage > 0) &&
roster.nextEnabled === (expectedPage < pageCount - 1),
`Expected correct final-camp roster navigation state on page ${expectedPage + 1}: ${JSON.stringify(roster)}`
);
const listBounds = roster.listBounds;
assert(isPositiveBounds(listBounds), `Expected final-camp roster list bounds on page ${expectedPage + 1}: ${JSON.stringify(roster)}`);
assert(
isBoundsInside(listBounds, { x: 0, y: 0, width: 1280, height: 720 }),
`Expected final-camp roster list inside the 1280x720 canvas: ${JSON.stringify(roster)}`
);
assert(
isPositiveBounds(roster.navigationBounds?.container) &&
isPositiveBounds(roster.navigationBounds?.previous) &&
isPositiveBounds(roster.navigationBounds?.label) &&
isPositiveBounds(roster.navigationBounds?.next) &&
isBoundsInside(roster.navigationBounds.container, { x: 0, y: 0, width: 1280, height: 720 }),
`Expected complete final-camp roster navigation bounds inside the HD canvas: ${JSON.stringify(roster)}`
);
assert(
isBoundsInside(roster.navigationBounds.previous, roster.navigationBounds.container) &&
isBoundsInside(roster.navigationBounds.label, roster.navigationBounds.container) &&
isBoundsInside(roster.navigationBounds.next, roster.navigationBounds.container) &&
listBounds.y + listBounds.height <= roster.navigationBounds.container.y,
`Expected final-camp roster controls to stay inside their navigation bar and below every row: ${JSON.stringify(roster)}`
);
roster.rowBounds.forEach((row, rowIndex) => {
assert(
row.unitId === roster.visibleUnitIds[rowIndex] &&
isPositiveBounds(row) &&
isBoundsInside(row, listBounds) &&
isBoundsInside(row, { x: 0, y: 0, width: 1280, height: 720 }) &&
row.y + row.height <= roster.navigationBounds.container.y,
`Expected final-camp roster row ${rowIndex + 1} to match its visible officer and remain inside the list and HD canvas: ${JSON.stringify({
row,
listBounds,
visibleUnitIds: roster.visibleUnitIds
})}`
);
});
for (let leftIndex = 0; leftIndex < roster.rowBounds.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < roster.rowBounds.length; rightIndex += 1) {
assert(
!boundsOverlap(roster.rowBounds[leftIndex], roster.rowBounds[rightIndex]),
`Expected final-camp roster rows on page ${expectedPage + 1} not to overlap: ${JSON.stringify({
left: roster.rowBounds[leftIndex],
right: roster.rowBounds[rightIndex]
})}`
);
}
}
}
async function readCampRosterNavigationPoint(page, direction) {
const probe = await page.evaluate((navigationDirection) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const canvas = document.querySelector('canvas');
const roster = window.__HEROS_DEBUG__?.camp()?.campRoster;
const bounds = roster?.navigationBounds?.[navigationDirection];
const enabled = navigationDirection === 'previous' ? roster?.previousEnabled : roster?.nextEnabled;
if (!scene || !canvas || !bounds || !enabled) {
return { ready: false, direction: navigationDirection, enabled: enabled ?? false, bounds: bounds ?? null, roster };
}
const canvasBounds = canvas.getBoundingClientRect();
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
ready: true,
direction: navigationDirection,
enabled,
bounds,
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
};
}, direction);
assert(
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
`Expected an enabled final-camp roster ${direction} control: ${JSON.stringify(probe)}`
);
return probe;
}
async function readCampRosterRowPoint(page, unitId) {
const probe = await page.evaluate((targetUnitId) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const canvas = document.querySelector('canvas');
const roster = window.__HEROS_DEBUG__?.camp()?.campRoster;
const bounds = roster?.rowBounds?.find((row) => row.unitId === targetUnitId);
if (!scene || !canvas || !bounds) {
return { ready: false, unitId: targetUnitId, bounds: bounds ?? null, roster };
}
const canvasBounds = canvas.getBoundingClientRect();
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
ready: true,
unitId: targetUnitId,
bounds,
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
};
}, unitId);
assert(
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
`Expected a visible final-camp roster row for ${unitId}: ${JSON.stringify(probe)}`
);
return probe;
}
function isPositiveBounds(bounds) {
return Boolean(
bounds &&
Number.isFinite(bounds.x) &&
Number.isFinite(bounds.y) &&
Number.isFinite(bounds.width) &&
Number.isFinite(bounds.height) &&
bounds.width > 0 &&
bounds.height > 0
);
}
function isBoundsInside(inner, outer, tolerance = 0.01) {
return (
isPositiveBounds(inner) &&
isPositiveBounds(outer) &&
inner.x >= outer.x - tolerance &&
inner.y >= outer.y - tolerance &&
inner.x + inner.width <= outer.x + outer.width + tolerance &&
inner.y + inner.height <= outer.y + outer.height + tolerance
);
}
function boundsOverlap(left, right, tolerance = 0.01) {
return (
left.x < right.x + right.width - tolerance &&
left.x + left.width > right.x + tolerance &&
left.y < right.y + right.height - tolerance &&
left.y + left.height > right.y + tolerance
);
}
async function waitForSortiePrep(page) {
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];