feat: improve first-session combat and visual assets

This commit is contained in:
2026-07-18 22:11:50 +09:00
parent 4a2a0dfb19
commit f29956b90e
82 changed files with 3640 additions and 839 deletions

View File

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