perf: warm first battle map before handoff

This commit is contained in:
2026-07-29 21:19:24 +09:00
parent 9e3c8b467a
commit a5a41ee388
3 changed files with 125 additions and 11 deletions

View File

@@ -164,7 +164,10 @@ try {
firstBattlePlayableMs = Date.now() - battleStartedAt;
}
assert(
battleReadyState.combatAssets?.baseReady === true && battleReadyState.combatAssets?.actionReady === false,
battleReadyState.mapTextureReady === true &&
battleReadyState.mapBackgroundReady === true &&
battleReadyState.combatAssets?.baseReady === true &&
battleReadyState.combatAssets?.actionReady === false,
`Expected deployment to open from base sheets before action sheets: ${JSON.stringify(battleReadyState)}`
);
assert(
@@ -193,6 +196,24 @@ try {
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 battleEntryFirstMapRequests = battleEntryOnly
.map((entry) => decodeURIComponent(entry.name))
.filter((name) => /\/first-battle-map-v2(?:-[^/?]+)?\.webp(?:[?#]|$)/i.test(name));
const storyFirstMapWarmupRequests = battleTransitionStart.resourceEntries
.map((entry) => decodeURIComponent(entry.name))
.filter((name) => /\/first-battle-map-v2(?:-[^/?]+)?\.webp(?:[?#]|$)/i.test(name));
assert(
battleTransitionStart.firstBattleWarmup?.mapTextureReady === true &&
battleTransitionStart.firstBattleWarmup?.mapTextureRetained === true &&
battleTransitionStart.firstBattleWarmup?.actionTextureKeysLoaded?.length === 0 &&
storyFirstMapWarmupRequests.length === 1 &&
battleEntryFirstMapRequests.length === 0,
`Expected the first battle handoff to reuse the story-warmed map without action-sheet preloads: ${JSON.stringify({
warmup: battleTransitionStart.firstBattleWarmup,
storyFirstMapWarmupRequests,
battleEntryFirstMapRequests
})}`
);
const storyTransition = segmentStats(storyOnly);
const battleEntryTransition = segmentStats(battleEntryOnly);
const battleDeferredTransition = segmentStats(battleDeferredOnly);
@@ -225,6 +246,13 @@ try {
entry: battleReadyState.combatAssets,
playable: battlePlayableState.combatAssets
},
firstBattleMapWarmup: {
story: battleTransitionStart.firstBattleWarmup,
battleMapTextureReady: battleReadyState.mapTextureReady,
battleMapBackgroundReady: battleReadyState.mapBackgroundReady,
warmupRequests: storyFirstMapWarmupRequests,
handoffRequests: battleEntryFirstMapRequests
},
combatAssetStallFallback
};
report.budget = evaluateBudgets(report, budgets);
@@ -406,6 +434,7 @@ async function advanceStoryUntilBattle(page, battleId) {
storyLastPage: story?.isLastPage === true,
storyTargetsBattle: story?.nextScene === 'BattleScene',
currentPageReady: story?.assetStreaming?.currentPageReady === true,
firstBattleWarmup: story?.assetStreaming?.firstBattleWarmup ?? null,
villageActive: activeScenes.includes('PrologueVillageScene'),
villageReady: village?.ready === true,
villageNavigationPending: village?.navigationPending === true,
@@ -428,6 +457,13 @@ async function advanceStoryUntilBattle(page, battleId) {
state.storyTargetsBattle &&
state.currentPageReady
) {
assert(
state.firstBattleWarmup?.active === true &&
state.firstBattleWarmup?.mapTextureReady === true &&
state.firstBattleWarmup?.mapTextureRetained === true &&
state.firstBattleWarmup?.actionTextureKeysLoaded?.length === 0,
`Expected final first-battle story page to retain its warmed map without action sheets: ${JSON.stringify(state.firstBattleWarmup)}`
);
const entries = await resourceEntries(page);
const startedAt = Date.now();
await page.keyboard.press('Space');
@@ -447,7 +483,11 @@ async function advanceStoryUntilBattle(page, battleId) {
)
);
}, battleId, { timeout: 5000 });
return { startedAt, resourceEntries: entries };
return {
startedAt,
resourceEntries: entries,
firstBattleWarmup: state.firstBattleWarmup
};
}
if (state.villageActive && state.villageReady && !state.villageNavigationPending) {
@@ -502,7 +542,12 @@ async function waitForBattleReady(page, battleId) {
state?.combatAssets?.baseReady === true &&
state?.resultVisible === false
)
? { phase: state.phase, combatAssets: state.combatAssets }
? {
phase: state.phase,
mapTextureReady: state.mapTextureReady,
mapBackgroundReady: state.mapBackgroundReady,
combatAssets: state.combatAssets
}
: false;
} catch {
return false;
@@ -611,6 +656,7 @@ async function verifyCombatAssetStallFallback(browserInstance, baseUrl, kind) {
battleUrl.searchParams.set('debugCombatAssetWatchdogMs', String(watchdogMs));
battleUrl.searchParams.set('assetStallQa', kind);
await page.goto(battleUrl.toString(), { waitUntil: 'domcontentloaded' });
await prewarmUnitActionAssetsModule(page, baseUrl);
const entry = await waitForBattleReady(page, 'first-battle-zhuo-commandery');
assert(
entry.phase === 'deployment' && entry.combatAssets?.baseReady === true,
@@ -699,6 +745,21 @@ async function verifyCombatAssetStallFallback(browserInstance, baseUrl, kind) {
}
}
async function prewarmUnitActionAssetsModule(page, baseUrl) {
const assetsDir = join(process.cwd(), 'dist', 'assets');
const candidates = existsSync(assetsDir)
? readdirSync(assetsDir).filter((file) => /^unitActionAssets-[^.]+\.js$/i.test(file))
: [];
assert(
candidates.length === 1,
`Expected exactly one built unit action asset module, found ${JSON.stringify(candidates)}.`
);
const moduleUrl = new URL(`assets/${candidates[0]}`, baseUrl).toString();
await page.evaluate(async (url) => {
await import(url);
}, moduleUrl);
}
async function resourceEntries(page) {
return page.evaluate(() =>
performance.getEntriesByType('resource').map((entry) => ({

View File

@@ -358,10 +358,14 @@ try {
resumedPrologueSave,
'resumed prologue story'
);
const firstBattleMapHandoffRequestStartIndex = requestedResourceUrls.length;
const firstBattleTransition = await advanceUntilBattle(page, 'first-battle-zhuo-commandery', {
startDeployment: false,
captureLastStory: true
});
const firstBattleMapHandoffRequests = requestedResourceUrls
.slice(firstBattleMapHandoffRequestStartIndex)
.filter((url) => /\/first-battle-map-v2(?:-[^/?]+)?\.webp(?:[?#]|$)/i.test(decodeURIComponent(url)));
assert(
firstBattleTransition.lastStory?.isLastPage === true &&
firstBattleTransition.lastStory?.nextScene === 'BattleScene' &&
@@ -380,10 +384,19 @@ try {
const firstDeploymentPanel = firstBattleDeployment?.battleHud?.deploymentPanel;
assert(
firstBattleDeployment?.phase === 'deployment' &&
firstBattleDeployment?.mapTextureReady === true &&
firstBattleDeployment?.mapBackgroundReady === true &&
firstBattleTransition.lastStory?.assetStreaming?.firstBattleWarmup?.mapTextureReady === true &&
firstBattleTransition.lastStory?.assetStreaming?.firstBattleWarmup?.mapTextureRetained === true &&
firstBattleMapHandoffRequests.length === 0 &&
firstDeploymentPanel?.recommended === true &&
firstDeploymentPanel?.changeCount === 0 &&
boundsInside(firstDeploymentPanel?.statusBounds, firstDeploymentPanel?.headerBounds),
`Expected first deployment to show an FHD-safe recommended-formation status chip: ${JSON.stringify(firstBattleDeployment)}`
`Expected first deployment to reuse the retained story-warmed map and show an FHD-safe recommended-formation status chip: ${JSON.stringify({
firstBattleDeployment,
warmup: firstBattleTransition.lastStory?.assetStreaming?.firstBattleWarmup,
firstBattleMapHandoffRequests
})}`
);
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-deployment.png`, fullPage: true });
await assertCanvasPainted(page, 'first battle deployment');
@@ -10394,6 +10407,16 @@ async function assertFreshStoryAssetStreaming(page, requestedResourceUrls, reque
repeatedSortieBackgrounds.add(departureProbe.story.background);
}
}
assert(
departureProbe.story?.assetStreaming?.firstBattleWarmup?.enabled === true &&
departureProbe.story.assetStreaming.firstBattleWarmup.pageIndex === 1 &&
departureProbe.story.assetStreaming.firstBattleWarmup.active === true &&
departureProbe.story.assetStreaming.firstBattleWarmup.mapTextureKey === 'battle-map-first' &&
departureProbe.story.assetStreaming.firstBattleWarmup.mapTextureReady === true &&
departureProbe.story.assetStreaming.firstBattleWarmup.mapTextureRetained === true &&
departureProbe.story.assetStreaming.firstBattleWarmup.actionTextureKeysLoaded.length === 0,
`Expected departure page 2 to warm and retain only the first battle map, without action sheets: ${JSON.stringify(departureProbe)}`
);
assert(
repeatedSortieBackgrounds.size >= 2,
`Expected repeated sortie story pages to distribute deterministic background variants: ${JSON.stringify([
@@ -10404,10 +10427,17 @@ async function assertFreshStoryAssetStreaming(page, requestedResourceUrls, reque
const actionRequests = requestedResourceUrls
.slice(requestStartIndex)
.filter((url) => /\/unit-[^/?]+-actions(?:-[^/?]+)?\.(?:png|webp)(?:[?#]|$)/i.test(decodeURIComponent(url)));
const firstBattleMapWarmupRequests = requestedResourceUrls
.slice(requestStartIndex)
.filter((url) => /\/first-battle-map-v2(?:-[^/?]+)?\.webp(?:[?#]|$)/i.test(decodeURIComponent(url)));
assert(
actionRequests.length === 0,
`Expected story streaming not to request battle action sheets: ${JSON.stringify(actionRequests)}`
);
assert(
firstBattleMapWarmupRequests.length === 1,
`Expected story streaming to request the first battle map exactly once on departure page 2: ${JSON.stringify(firstBattleMapWarmupRequests)}`
);
}
async function advanceOneStoryPage(page, expectedPageIndex) {

View File

@@ -1,4 +1,5 @@
import Phaser from 'phaser';
import firstBattleMapUrl from '../../assets/images/battle/first-battle-map-v2.webp';
import { soundDirector } from '../audio/SoundDirector';
import { musicTracks, type MusicTrackKey } from '../audio/audioAssets';
import {
@@ -102,6 +103,7 @@ type StoryPageAssetPlan = {
backgrounds: Array<{ key: string; url: string }>;
portraits: PortraitAssetEntry[];
unitTextureKeys: string[];
retainedImages: Array<{ key: string; url: string }>;
};
type StoryEnvironmentParticleView = {
@@ -208,6 +210,7 @@ const firstBattleVictoryPageIds = new Set([
'first-victory-next'
]);
const firstBattleId = 'first-battle-zhuo-commandery';
const firstBattleMapTextureKey = 'battle-map-first';
const firstBattleWarmupUnitTextureKeys = [
'unit-rebel',
'unit-rebel-archer',
@@ -490,7 +493,21 @@ export class StoryScene extends Phaser.Scene {
: [],
currentUnitActionTexturesLoaded: page
? this.storyPageAssetPlan(this.pageIndex).unitTextureKeys.filter((key) => this.textures.exists(`${key}-actions`))
: []
: [],
firstBattleWarmup: {
enabled: this.isFirstBattlePrelude(),
pageIndex: Math.min(1, this.pages.length - 1),
active: this.isFirstBattleWarmupPage(this.pageIndex),
mapTextureKey: firstBattleMapTextureKey,
mapTextureReady: this.isFirstBattlePrelude() && this.textures.exists(firstBattleMapTextureKey),
mapTextureRetained:
this.isFirstBattlePrelude() &&
this.textures.exists(firstBattleMapTextureKey) &&
!this.ownedStoryTextureKeys.has(firstBattleMapTextureKey),
actionTextureKeysLoaded: firstBattleWarmupUnitTextureKeys
.map((key) => `${key}-actions`)
.filter((key) => this.textures.exists(key))
}
}
};
}
@@ -705,8 +722,9 @@ export class StoryScene extends Phaser.Scene {
};
const imageLoads = [
...plan.backgrounds.map(({ key, url }) => ({ key, url })),
...plan.portraits.map(({ textureKey: key, url }) => ({ key, url }))
...plan.backgrounds.map(({ key, url }) => ({ key, url, retained: false })),
...plan.portraits.map(({ textureKey: key, url }) => ({ key, url, retained: false })),
...plan.retainedImages.map(({ key, url }) => ({ key, url, retained: true }))
].filter(({ key }, index, entries) => {
return !this.textures.exists(key) && entries.findIndex((entry) => entry.key === key) === index;
});
@@ -729,8 +747,10 @@ export class StoryScene extends Phaser.Scene {
}
loadUnitBases();
});
imageLoads.forEach(({ key, url }) => {
this.ownedStoryTextureKeys.add(key);
imageLoads.forEach(({ key, url, retained }) => {
if (!retained) {
this.ownedStoryTextureKeys.add(key);
}
this.load.image(key, url);
});
this.load.start();
@@ -746,7 +766,7 @@ export class StoryScene extends Phaser.Scene {
private storyPageAssetPlan(pageIndex: number): StoryPageAssetPlan {
const page = this.pages[pageIndex];
if (!page) {
return { backgrounds: [], portraits: [], unitTextureKeys: [] };
return { backgrounds: [], portraits: [], unitTextureKeys: [], retainedImages: [] };
}
const backgroundKey = this.pageBackgroundAssetKey(page);
@@ -774,7 +794,10 @@ export class StoryScene extends Phaser.Scene {
unitTextureKeys: Array.from(new Set([
...storyCutsceneActorTextureKeys(page.cutscene),
...warmupUnitTextureKeys
]))
])),
retainedImages: this.isFirstBattleWarmupPage(pageIndex)
? [{ key: firstBattleMapTextureKey, url: firstBattleMapUrl }]
: []
};
}