diff --git a/scripts/measure-performance.mjs b/scripts/measure-performance.mjs index 1123f04..a10b1a7 100644 --- a/scripts/measure-performance.mjs +++ b/scripts/measure-performance.mjs @@ -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) => ({ diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 3d98c80..22c2ee1 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -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) { diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 0a19e33..1f9ad35 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -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 }] + : [] }; }