feat: improve first-session combat and visual assets
This commit is contained in:
@@ -16,13 +16,15 @@ const legacyUiScale = 1.5;
|
||||
const budgets = {
|
||||
titleReadyMs: readBudget('PERF_BUDGET_TITLE_READY_MS', 30000),
|
||||
storyReadyFromNewGameMs: readBudget('PERF_BUDGET_STORY_READY_MS', 30000),
|
||||
firstBattleReadyFromStoryMs: readBudget('PERF_BUDGET_BATTLE_READY_MS', 60000),
|
||||
firstBattlePlayableFromStoryMs: readBudget('PERF_BUDGET_BATTLE_PLAYABLE_MS', 70000),
|
||||
firstBattleReadyFromStoryMs: readBudget('PERF_BUDGET_BATTLE_READY_MS', 6000),
|
||||
firstBattlePlayableFromStoryMs: readBudget('PERF_BUDGET_BATTLE_PLAYABLE_MS', 25000),
|
||||
firstLoadEncodedKB: readBudget('PERF_BUDGET_FIRST_LOAD_KB', 10000),
|
||||
storyTransitionEncodedKB: readBudget('PERF_BUDGET_STORY_TRANSITION_KB', 15000),
|
||||
battleEntryTransitionEncodedKB: readBudget('PERF_BUDGET_BATTLE_ENTRY_KB', 45000),
|
||||
battleTransitionEncodedKB: readBudget('PERF_BUDGET_BATTLE_TRANSITION_KB', 110000),
|
||||
storyBattleTransitionEncodedKB: readBudget('PERF_BUDGET_STORY_BATTLE_TRANSITION_KB', 125000),
|
||||
storyActionSheetRequests: 0,
|
||||
battleEntryActionSheetRequests: 0,
|
||||
largestJsKB: readBudget('PERF_BUDGET_LARGEST_JS_KB', 2500)
|
||||
};
|
||||
|
||||
@@ -97,26 +99,52 @@ try {
|
||||
const storyMs = Date.now() - storyStartedAt;
|
||||
const storyEntries = await resourceEntries(page);
|
||||
|
||||
const battleStartedAt = Date.now();
|
||||
await advanceStoryUntilBattle(page, 'first-battle-zhuo-commandery');
|
||||
const battleTransitionStart = await advanceStoryUntilBattle(page, 'first-battle-zhuo-commandery');
|
||||
const battleStartedAt = battleTransitionStart.startedAt;
|
||||
const battleReadyState = await waitForBattleReady(page, 'first-battle-zhuo-commandery');
|
||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||
const battleMs = Date.now() - battleStartedAt;
|
||||
const battleEntries = await resourceEntries(page);
|
||||
const battleReadyMs = Date.now() - battleStartedAt;
|
||||
const battleReadyEntries = await resourceEntries(page);
|
||||
|
||||
let firstBattlePlayableMs = battleMs;
|
||||
let firstBattlePlayableMs = battleReadyMs;
|
||||
let battlePlayableState = battleReadyState;
|
||||
if (battleReadyState.phase === 'deployment') {
|
||||
const playableStartedAt = Date.now();
|
||||
await clickBattleDeploymentStart(page);
|
||||
await waitForBattlePlayable(page, 'first-battle-zhuo-commandery');
|
||||
firstBattlePlayableMs += Date.now() - playableStartedAt;
|
||||
battlePlayableState = await waitForBattlePlayable(page, 'first-battle-zhuo-commandery');
|
||||
firstBattlePlayableMs = Date.now() - battleStartedAt;
|
||||
}
|
||||
assert(
|
||||
battleReadyState.combatAssets?.baseReady === true && battleReadyState.combatAssets?.actionReady === false,
|
||||
`Expected deployment to open from base sheets before action sheets: ${JSON.stringify(battleReadyState)}`
|
||||
);
|
||||
assert(
|
||||
battlePlayableState.combatAssets?.status === 'ready' || battlePlayableState.combatAssets?.status === 'degraded',
|
||||
`Expected combat assets to settle before battle play: ${JSON.stringify(battlePlayableState)}`
|
||||
);
|
||||
if (battlePlayableState.combatAssets?.status === 'ready') {
|
||||
assert(
|
||||
battlePlayableState.combatAssets.actionReady === true && battlePlayableState.combatAssets.portraitReady === true,
|
||||
`Expected all deferred combat assets before battle play: ${JSON.stringify(battlePlayableState)}`
|
||||
);
|
||||
}
|
||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||
const battleEntries = await resourceEntries(page);
|
||||
const combatAssetStallFallback = measureRenderer === 'webgl'
|
||||
? {
|
||||
action: await verifyCombatAssetStallFallback(browser, targetUrl, 'action'),
|
||||
portrait: await verifyCombatAssetStallFallback(browser, targetUrl, 'portrait')
|
||||
}
|
||||
: null;
|
||||
|
||||
const titleNames = new Set(titleEntries.map((entry) => entry.name));
|
||||
const storyNames = new Set(storyEntries.map((entry) => entry.name));
|
||||
const preBattleNames = new Set(battleTransitionStart.resourceEntries.map((entry) => entry.name));
|
||||
const battleReadyNames = new Set(battleReadyEntries.map((entry) => entry.name));
|
||||
const storyOnly = storyEntries.filter((entry) => !titleNames.has(entry.name));
|
||||
const battleOnly = battleEntries.filter((entry) => !storyNames.has(entry.name));
|
||||
const battleEntryOnly = battleReadyEntries.filter((entry) => !preBattleNames.has(entry.name));
|
||||
const battleOnly = battleEntries.filter((entry) => !preBattleNames.has(entry.name));
|
||||
const battleDeferredOnly = battleOnly.filter((entry) => !battleReadyNames.has(entry.name));
|
||||
const storyTransition = segmentStats(storyOnly);
|
||||
const battleEntryTransition = segmentStats(battleEntryOnly);
|
||||
const battleDeferredTransition = segmentStats(battleDeferredOnly);
|
||||
const battleTransition = segmentStats(battleOnly);
|
||||
const report = {
|
||||
targetUrl,
|
||||
@@ -127,17 +155,26 @@ try {
|
||||
timingsMs: {
|
||||
titleReady: titleMs,
|
||||
storyReadyFromNewGame: storyMs,
|
||||
firstBattleReadyFromStory: battleMs,
|
||||
firstBattleReadyFromStory: battleReadyMs,
|
||||
firstBattlePlayableFromStory: firstBattlePlayableMs
|
||||
},
|
||||
build: buildStats(),
|
||||
firstLoad: segmentStats(titleEntries),
|
||||
storyTransition,
|
||||
battleEntryTransition,
|
||||
battleDeferredTransition,
|
||||
battleTransition,
|
||||
assetPolicy: {
|
||||
storyActionSheetRequests: actionSheetFiles(storyOnly),
|
||||
battleEntryActionSheetRequests: actionSheetFiles(battleEntryOnly),
|
||||
battleDeferredActionSheetRequests: actionSheetFiles(battleDeferredOnly),
|
||||
battleActionSheetRequests: actionSheetFiles(battleOnly)
|
||||
}
|
||||
},
|
||||
battleAssetStreaming: {
|
||||
entry: battleReadyState.combatAssets,
|
||||
playable: battlePlayableState.combatAssets
|
||||
},
|
||||
combatAssetStallFallback
|
||||
};
|
||||
report.budget = evaluateBudgets(report, budgets);
|
||||
|
||||
@@ -147,6 +184,8 @@ try {
|
||||
timingsMs: report.timingsMs,
|
||||
firstLoad: report.firstLoad.summary,
|
||||
storyTransition: report.storyTransition.summary,
|
||||
battleEntryTransition: report.battleEntryTransition.summary,
|
||||
battleDeferredTransition: report.battleDeferredTransition.summary,
|
||||
battleTransition: report.battleTransition.summary,
|
||||
largestJs: report.build.javascript[0] ?? null,
|
||||
budget: report.budget.summary
|
||||
@@ -283,21 +322,36 @@ async function waitForStoryReady(page) {
|
||||
|
||||
async function advanceStoryUntilBattle(page, battleId) {
|
||||
for (let i = 0; i < 48; i += 1) {
|
||||
const ready = await page.evaluate((expectedBattleId) => {
|
||||
const state = await page.evaluate((expectedBattleId) => {
|
||||
try {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
return state?.scene === 'BattleScene' && state?.battleId === expectedBattleId;
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.();
|
||||
return {
|
||||
battleReady: battle?.scene === 'BattleScene' && battle?.battleId === expectedBattleId,
|
||||
storyReady: story?.ready === true,
|
||||
storyLastPage: story?.isLastPage === true,
|
||||
storyTargetsBattle: story?.nextScene === 'BattleScene',
|
||||
currentPageReady: story?.assetStreaming?.currentPageReady === true
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
return {};
|
||||
}
|
||||
}, battleId);
|
||||
if (ready) {
|
||||
return;
|
||||
if (state.battleReady) {
|
||||
throw new Error('Battle became ready before the transition start could be measured.');
|
||||
}
|
||||
if (state.storyReady && state.storyLastPage && state.storyTargetsBattle && state.currentPageReady) {
|
||||
await page.waitForTimeout(600);
|
||||
const entries = await resourceEntries(page);
|
||||
const startedAt = Date.now();
|
||||
await page.keyboard.press('Space');
|
||||
return { startedAt, resourceEntries: entries };
|
||||
}
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForTimeout(220);
|
||||
}
|
||||
throw new Error(`Story did not reach the battle transition for ${battleId}.`);
|
||||
}
|
||||
|
||||
async function waitForBattleReady(page, battleId) {
|
||||
@@ -309,9 +363,10 @@ async function waitForBattleReady(page, battleId) {
|
||||
state?.battleId === expectedBattleId &&
|
||||
(state?.phase === 'deployment' || state?.phase === 'idle') &&
|
||||
state?.mapBackgroundReady === true &&
|
||||
state?.combatAssets?.baseReady === true &&
|
||||
state?.resultVisible === false
|
||||
)
|
||||
? { phase: state.phase }
|
||||
? { phase: state.phase, combatAssets: state.combatAssets }
|
||||
: false;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -320,7 +375,7 @@ async function waitForBattleReady(page, battleId) {
|
||||
}
|
||||
|
||||
async function waitForBattlePlayable(page, battleId) {
|
||||
await page.waitForFunction((expectedBattleId) => {
|
||||
return page.waitForFunction((expectedBattleId) => {
|
||||
try {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
@@ -328,13 +383,130 @@ async function waitForBattlePlayable(page, battleId) {
|
||||
state?.battleId === expectedBattleId &&
|
||||
state?.phase === 'idle' &&
|
||||
state?.mapBackgroundReady === true &&
|
||||
(state?.combatAssets?.status === 'ready' || state?.combatAssets?.status === 'degraded') &&
|
||||
state?.triggeredBattleEvents?.includes('opening') &&
|
||||
state?.resultVisible === false
|
||||
);
|
||||
)
|
||||
? { phase: state.phase, combatAssets: state.combatAssets }
|
||||
: false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, battleId, { timeout: 30000 });
|
||||
}, battleId, { timeout: 30000 }).then((handle) => handle.jsonValue());
|
||||
}
|
||||
|
||||
async function verifyCombatAssetStallFallback(browserInstance, baseUrl, kind) {
|
||||
const context = await browserInstance.newContext(desktopBrowserContextOptions);
|
||||
const pageErrors = [];
|
||||
let stalledUrl = null;
|
||||
const stallReleaseMs = 1800;
|
||||
const watchdogMs = 450;
|
||||
|
||||
try {
|
||||
await context.addInitScript(() => window.localStorage.clear());
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
await page.route('**/*', async (route) => {
|
||||
const url = route.request().url();
|
||||
const fileName = decodeURIComponent(url.split('/').pop()?.split('?')[0] ?? '');
|
||||
const shouldStall = kind === 'action'
|
||||
? /^unit-.*-actions-[^.]+\.png$/i.test(fileName)
|
||||
: /^(?:liu-bei|guan-yu|zhang-fei)-[^.]+\.webp$/i.test(fileName);
|
||||
const isSelectedStall = shouldStall && (!stalledUrl || stalledUrl === url);
|
||||
if (isSelectedStall) {
|
||||
stalledUrl ??= url;
|
||||
await delay(stallReleaseMs);
|
||||
await route.abort('timedout');
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
const battleUrl = new URL(baseUrl);
|
||||
battleUrl.searchParams.set('debug', '1');
|
||||
battleUrl.searchParams.set('renderer', 'webgl');
|
||||
battleUrl.searchParams.set('debugBattle', 'first-battle-zhuo-commandery');
|
||||
battleUrl.searchParams.set('debugCombatAssetWatchdogMs', String(watchdogMs));
|
||||
battleUrl.searchParams.set('assetStallQa', kind);
|
||||
await page.goto(battleUrl.toString(), { waitUntil: 'domcontentloaded' });
|
||||
const entry = await waitForBattleReady(page, 'first-battle-zhuo-commandery');
|
||||
assert(
|
||||
entry.phase === 'deployment' && entry.combatAssets?.baseReady === true,
|
||||
`Expected ${kind} stall QA to reach deployment from base sheets: ${JSON.stringify(entry)}`
|
||||
);
|
||||
|
||||
await clickBattleDeploymentStart(page);
|
||||
const playable = await waitForBattlePlayable(page, 'first-battle-zhuo-commandery');
|
||||
const settled = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.combatAssets ?? null);
|
||||
assert(stalledUrl, `Expected ${kind} stall QA to intercept one deferred asset request.`);
|
||||
assert(
|
||||
playable.combatAssets?.status === 'degraded' &&
|
||||
settled?.pendingDeploymentConfirmation === false &&
|
||||
settled?.settlementCount === 1,
|
||||
`Expected ${kind} stall watchdog to settle once and resume pending combat: ${JSON.stringify({ playable, settled, stalledUrl })}`
|
||||
);
|
||||
|
||||
const fallback = await page.evaluate((assetKind) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
if (!battle) {
|
||||
return null;
|
||||
}
|
||||
if (assetKind === 'action') {
|
||||
const missing = new Set(battle.combatAssets?.missingActionKeys ?? []);
|
||||
const unitTextures = (battle.units ?? []).map((unit) => ({
|
||||
id: unit.id,
|
||||
textureBase: unit.textureBase,
|
||||
actionTexture: unit.actionTexture
|
||||
}));
|
||||
return {
|
||||
missingKeys: [...missing],
|
||||
fallbackUnitIds: unitTextures
|
||||
.filter((unit) => missing.has(unit.textureBase) && unit.actionTexture === unit.textureBase)
|
||||
.map((unit) => unit.id),
|
||||
unitTextures
|
||||
};
|
||||
}
|
||||
return {
|
||||
missingKeys: [...(battle.combatAssets?.missingPortraitKeys ?? [])],
|
||||
fallbackUnitIds: (battle.units ?? [])
|
||||
.filter((unit) => unit.combatPortraitKey && !unit.combatPortraitReady)
|
||||
.map((unit) => unit.id)
|
||||
};
|
||||
}, kind);
|
||||
assert(
|
||||
fallback?.missingKeys?.length > 0 && fallback?.fallbackUnitIds?.length > 0,
|
||||
`Expected ${kind} stall QA to expose a concrete fallback: ${JSON.stringify(fallback)}`
|
||||
);
|
||||
|
||||
await page.waitForTimeout(stallReleaseMs + 350);
|
||||
const afterLateFailure = await page.evaluate(() => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return {
|
||||
phase: battle?.phase ?? null,
|
||||
status: battle?.combatAssets?.status ?? null,
|
||||
pending: battle?.combatAssets?.pendingDeploymentConfirmation ?? null,
|
||||
settlementCount: battle?.combatAssets?.settlementCount ?? null
|
||||
};
|
||||
});
|
||||
assert(
|
||||
afterLateFailure.phase === 'idle' &&
|
||||
afterLateFailure.status === 'degraded' &&
|
||||
afterLateFailure.pending === false &&
|
||||
afterLateFailure.settlementCount === 1,
|
||||
`Expected late ${kind} completion/error callbacks to remain inert: ${JSON.stringify(afterLateFailure)}`
|
||||
);
|
||||
assert(pageErrors.length === 0, `Expected no page errors during ${kind} stall fallback: ${JSON.stringify(pageErrors)}`);
|
||||
|
||||
return {
|
||||
status: afterLateFailure.status,
|
||||
settlementCount: afterLateFailure.settlementCount,
|
||||
missingKeyCount: fallback.missingKeys.length,
|
||||
fallbackUnitIds: fallback.fallbackUnitIds,
|
||||
stalledFile: decodeURIComponent(stalledUrl.split('/').pop()?.split('?')[0] ?? '')
|
||||
};
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function resourceEntries(page) {
|
||||
@@ -430,9 +602,11 @@ function evaluateBudgets(report, budgetValues) {
|
||||
['firstBattlePlayableFromStoryMs', report.timingsMs.firstBattlePlayableFromStory, budgetValues.firstBattlePlayableFromStoryMs],
|
||||
['firstLoadEncodedKB', report.firstLoad.summary.encodedKB, budgetValues.firstLoadEncodedKB],
|
||||
['storyTransitionEncodedKB', report.storyTransition.summary.encodedKB, budgetValues.storyTransitionEncodedKB],
|
||||
['battleEntryTransitionEncodedKB', report.battleEntryTransition.summary.encodedKB, budgetValues.battleEntryTransitionEncodedKB],
|
||||
['battleTransitionEncodedKB', report.battleTransition.summary.encodedKB, budgetValues.battleTransitionEncodedKB],
|
||||
['storyBattleTransitionEncodedKB', storyBattleTransitionEncodedKB, budgetValues.storyBattleTransitionEncodedKB],
|
||||
['storyActionSheetRequests', report.assetPolicy.storyActionSheetRequests.length, budgetValues.storyActionSheetRequests],
|
||||
['battleEntryActionSheetRequests', report.assetPolicy.battleEntryActionSheetRequests.length, budgetValues.battleEntryActionSheetRequests],
|
||||
['largestJsKB', largestJs, budgetValues.largestJsKB]
|
||||
];
|
||||
const failures = checks
|
||||
|
||||
Reference in New Issue
Block a user