import { spawn, spawnSync } from 'node:child_process'; import { mkdirSync } from 'node:fs'; import { chromium } from 'playwright'; const targetUrl = withDebugParam(process.env.RELEASE_QA_URL ?? 'http://127.0.0.1:4173/'); const screenshotDir = 'dist'; const expectedTitle = '\uC0BC\uAD6D\uC9C0: \uC138 \uD615\uC81C\uC758 \uB9F9\uC138'; const firstBattleUnlockLabel = '\uD669\uAC74 \uC794\uB2F9 \uCD94\uACA9 \uAC1C\uBC29'; const relevantLogPattern = /asset|audio|cannot|failed|map|missing|portrait|sprite|story|texture|unit/i; let serverProcess; let browser; try { runCommand('node', ['scripts/verify-static-data.mjs']); mkdirSync(screenshotDir, { recursive: true }); serverProcess = await ensurePreviewServer(targetUrl); runCommand(process.execPath, ['scripts/verify-save-retry-flow.mjs'], { VERIFY_URL: targetUrl }); browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); const consoleMessages = []; const pageErrors = []; const requestFailures = []; page.on('console', (message) => { consoleMessages.push({ type: message.type(), text: message.text() }); }); page.on('pageerror', (error) => { pageErrors.push(error.message); }); page.on('requestfailed', (request) => { requestFailures.push({ url: request.url(), type: request.resourceType(), failure: request.failure()?.errorText ?? 'unknown' }); }); await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); await page.evaluate(() => window.localStorage.clear()); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); await assertReleaseShellReady(page); await page.screenshot({ path: `${screenshotDir}/rc-title-first-run.png`, fullPage: true }); await assertCanvasPainted(page, 'title first run'); const emptySave = await readCampaignSave(page); assert(!emptySave.current && !emptySave.slot1, `Expected first run without campaign save: ${JSON.stringify(emptySave)}`); await page.mouse.click(962, 240); await waitForStoryReady(page); await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true }); await assertCanvasPainted(page, 'new game story'); await advanceUntilBattle(page, 'first-battle-zhuo-commandery'); await page.screenshot({ path: `${screenshotDir}/rc-first-battle.png`, fullPage: true }); await assertCanvasPainted(page, 'first battle'); const firstBattleProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const state = window.__HEROS_DEBUG__?.battle(); const sideTexts = textValues(scene?.sidePanelObjects); return { battleId: state?.battleId, objectiveText: scene?.objectiveTrackerText?.text, objectiveSubText: scene?.objectiveTrackerSubText?.text, sideTexts }; function textValues(objects = []) { return objects.filter((object) => object?.type === 'Text').map((object) => object.text); } }); assert(firstBattleProbe.battleId === 'first-battle-zhuo-commandery', `Expected first battle: ${JSON.stringify(firstBattleProbe)}`); assert(firstBattleProbe.objectiveText?.startsWith('승리 목표:'), `Expected clear victory objective label: ${JSON.stringify(firstBattleProbe)}`); assert(firstBattleProbe.objectiveSubText?.includes('패배 조건:'), `Expected clear defeat condition label: ${JSON.stringify(firstBattleProbe)}`); assert(firstBattleProbe.objectiveSubText?.includes('진군:'), `Expected tactical route in objective tracker: ${JSON.stringify(firstBattleProbe)}`); assert( firstBattleProbe.sideTexts.some((text) => text.includes('출진 군세')) && !firstBattleProbe.sideTexts.some((text) => text.includes('...')), `Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}` ); await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await waitForBattleOutcome(page, 'victory'); await page.screenshot({ path: `${screenshotDir}/rc-first-battle-result.png`, fullPage: true }); await assertCanvasPainted(page, 'first battle result'); const resultProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const state = window.__HEROS_DEBUG__?.battle(); return { outcome: state?.battleOutcome, resultTexts: textValues(scene?.resultObjects) }; function textValues(objects = []) { return objects.filter((object) => object?.type === 'Text').map((object) => object.text); } }); assert(resultProbe.outcome === 'victory', `Expected forced victory result: ${JSON.stringify(resultProbe)}`); assert(resultProbe.resultTexts.includes('목표 정산'), `Expected result objective settlement title: ${JSON.stringify(resultProbe.resultTexts)}`); assert(resultProbe.resultTexts.some((text) => text.includes('목표 보상')), `Expected reward panel to name objective rewards: ${JSON.stringify(resultProbe.resultTexts)}`); assert( resultProbe.resultTexts.some((text) => text.includes(`해금 ${firstBattleUnlockLabel}`)), `Expected result reward panel to surface first battle unlock label: ${JSON.stringify(resultProbe.resultTexts)}` ); assert(!resultProbe.resultTexts.some((text) => text.includes('미달')), `Expected result screen to avoid harsh optional-goal wording: ${JSON.stringify(resultProbe.resultTexts)}`); const firstResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const firstResultSave = await readCampaignSave(page); const firstResultPerformance = firstResultState?.sortieEvaluation?.snapshot; assert( firstResultState?.sortieEvaluation?.available === true && firstResultState.sortieEvaluation.open === false && ['S', 'A', 'B', 'C', 'D'].includes(firstResultState.sortieEvaluation.grade) && Number.isFinite(firstResultState.sortieEvaluation.score), `Expected the first result to expose a closed formation evaluation with a grade: ${JSON.stringify(firstResultState?.sortieEvaluation)}` ); assert( firstResultPerformance?.selectedUnitIds?.length === firstResultState?.deployedAllyIds?.length && firstResultPerformance.selectedUnitIds.every((unitId) => firstResultState.deployedAllyIds.includes(unitId)) && firstResultPerformance.selectedUnitIds.every((unitId) => ['front', 'flank', 'support', 'reserve'].includes(firstResultPerformance.formationAssignments?.[unitId])) && firstResultPerformance.unitStats?.length === firstResultPerformance.selectedUnitIds.length && !Object.prototype.hasOwnProperty.call(firstResultPerformance, 'sortieItemAssignments') && !Object.prototype.hasOwnProperty.call(firstResultPerformance, 'itemAssignments'), `Expected the first result snapshot to materialize every deployed officer and role without supplies: ${JSON.stringify(firstResultPerformance)}` ); assert( sameJsonValue(firstResultSave.current?.firstBattleReport?.sortiePerformance, firstResultPerformance) && sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortiePerformance, firstResultPerformance) && sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortiePerformance, firstResultPerformance) && sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortiePerformance, firstResultPerformance), `Expected the first formation result snapshot to synchronize across report, history, current, and slot-1 saves: ${JSON.stringify(firstResultSave)}` ); const firstEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle'); await page.mouse.click(firstEvaluationTogglePoint.x, firstEvaluationTogglePoint.y); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true, undefined, { timeout: 30000 }); const firstEvaluationProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const state = window.__HEROS_DEBUG__?.battle(); return { evaluation: state?.sortieEvaluation, texts: (scene?.resultFormationReviewObjects ?? []) .filter((object) => object?.type === 'Text') .map((object) => object.text) }; }); assert( firstEvaluationProbe.texts.includes('이번 편성 평가') && firstEvaluationProbe.texts.some((text) => text.includes('명단·역할만 저장') && text.includes('장비·보급 제외')) && firstEvaluationProbe.evaluation?.presetCards?.length === 3 && firstEvaluationProbe.evaluation.presetCards.every((card) => card.saved === false && card.matching === false && card.actionButtonBounds), `Expected the visible first-battle evaluation to explain its scope and show three empty preset actions: ${JSON.stringify(firstEvaluationProbe)}` ); await page.screenshot({ path: `${screenshotDir}/rc-first-battle-formation-evaluation.png`, fullPage: true }); await assertCanvasPainted(page, 'first battle formation evaluation'); const firstEvaluationClosePoint = await readBattleResultEvaluationControlPoint(page, 'close'); await page.mouse.click(firstEvaluationClosePoint.x, firstEvaluationClosePoint.y); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false, undefined, { timeout: 30000 }); await page.mouse.click(738, 642); await waitForCampAfterBattleResult(page); await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp'); const firstCampProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); return { state: window.__HEROS_DEBUG__?.camp(), summary: scene?.reportSummary?.(), texts: textValues(scene?.contentObjects) }; function textValues(objects = []) { return objects.filter((object) => object?.type === 'Text').map((object) => object.text); } }); assert(firstCampProbe.state?.campaign?.step === 'first-camp', `Expected victory to return to first camp: ${JSON.stringify(firstCampProbe.state)}`); assert( firstCampProbe.state?.nextSortieBattleId === 'second-battle-yellow-turban-pursuit', `Expected first camp sortie flow to prepare unlocked second battle: ${JSON.stringify(firstCampProbe.state)}` ); assert( ['liu-bei', 'guan-yu', 'zhang-fei'].every((unitId) => firstCampProbe.state?.sortieDeploymentPreview?.some((slot) => slot.unitId === unitId) ), `Expected first camp deployment preview to keep the founding trio assigned: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}` ); assert(firstCampProbe.state?.sortieHasBattle === true, `Expected first camp to expose a playable next battle: ${JSON.stringify(firstCampProbe.state)}`); assert( firstCampProbe.state?.sortieRecommendationEnabled === true, `Expected first camp to support recommended sortie planning: ${JSON.stringify(firstCampProbe.state)}` ); assert( firstCampProbe.state?.sortiePlan?.selectedCount === firstCampProbe.state?.sortieDeploymentPreview?.length, `Expected deployment preview to match selected sortie count: ${JSON.stringify(firstCampProbe.state?.sortiePlan)} / ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}` ); assertUnique( firstCampProbe.state?.sortieDeploymentPreview?.map((slot) => slot.unitId), `Expected first camp deployment preview to avoid duplicate units: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}` ); assertUnique( firstCampProbe.state?.sortieDeploymentPreview?.map((slot) => `${slot.x},${slot.y}`), `Expected first camp deployment preview to avoid duplicate tiles: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}` ); assert(firstCampProbe.summary?.includes('보유 군자금'), `Expected camp summary to show held gold: ${JSON.stringify(firstCampProbe)}`); assert(firstCampProbe.texts.some((text) => text.includes('전투 보상')), `Expected camp report to show battle reward: ${JSON.stringify(firstCampProbe.texts)}`); assert( firstCampProbe.texts.some((text) => text.includes(`다음 ${firstBattleUnlockLabel}`)), `Expected camp report to surface first battle unlock label: ${JSON.stringify(firstCampProbe.texts)}` ); assert( firstCampProbe.state?.report?.campaignRewards?.unlocks?.some( (unlock) => unlock.battleId === 'second-battle-yellow-turban-pursuit' && unlock.title === firstBattleUnlockLabel ), `Expected campaign report debug state to retain first battle unlock reward: ${JSON.stringify(firstCampProbe.state?.report?.campaignRewards)}` ); const firstCampSelectedSortieUnitIds = firstCampProbe.state?.selectedSortieUnitIds ?? []; const firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? []; await page.mouse.click(1160, 38); await waitForSortiePrep(page); await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-prep.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp sortie prep'); const firstSortieBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( firstSortieBriefingState?.stagedSortiePrep === true && firstSortieBriefingState?.sortiePrepStep === 'briefing' && firstSortieBriefingState?.sortiePrimaryAction?.kind === 'next' && firstSortieBriefingState?.sortiePrimaryAction?.nextStep === 'formation', `Expected the first sortie to open at the staged battlefield briefing: ${JSON.stringify(firstSortieBriefingState)}` ); await advanceSortiePrepStep(page, 'formation'); await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-formation.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp sortie formation'); const firstSortieSynergy = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieSynergyPreview); assert( firstSortieSynergy?.activeBondCount === 3 && firstSortieSynergy?.coveredRoleCount === 3 && firstSortieSynergy?.strongestBond?.damageBonus === 9 && firstSortieSynergy?.strongestBond?.chainRate === 18 && firstSortieSynergy?.firstPursuitTrinityConfigured === true, `Expected first sortie to preview three bonds, the strongest effect, and trinity: ${JSON.stringify(firstSortieSynergy)}` ); await advanceSortiePrepStep(page, 'loadout'); await page.mouse.click(1116, 656); await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit'); await page.screenshot({ path: `${screenshotDir}/rc-second-battle-from-first-camp.png`, fullPage: true }); await assertCanvasPainted(page, 'second battle from first camp'); const secondBattleProbe = await readBattleDoctrineProbe(page); assert(secondBattleProbe?.battleId === 'second-battle-yellow-turban-pursuit', `Expected first camp sortie to enter second battle: ${JSON.stringify(secondBattleProbe)}`); assertSameMembers( secondBattleProbe?.selectedSortieUnitIds, firstCampSelectedSortieUnitIds, `Expected second battle to receive first camp selected sortie ids: ${JSON.stringify(secondBattleProbe?.selectedSortieUnitIds)} / ${JSON.stringify(firstCampSelectedSortieUnitIds)}` ); assertSameMembers( secondBattleProbe?.deployedAllyIds, firstCampSelectedSortieUnitIds, `Expected second battle deployed allies to match first camp sortie ids: ${JSON.stringify(secondBattleProbe?.deployedAllyIds)} / ${JSON.stringify(firstCampSelectedSortieUnitIds)}` ); assertDeploymentMatchesPreview( secondBattleProbe?.deployedAllyPositions, firstCampDeploymentPreview, `Expected second battle ally positions to match first camp deployment preview: ${JSON.stringify(secondBattleProbe?.deployedAllyPositions)} / ${JSON.stringify(firstCampDeploymentPreview)}` ); assertSortieDoctrine(secondBattleProbe, { requireFullCoverage: true, requireVisibleSeals: true }); assert( secondBattleProbe.firstSortieRoleEffects?.active === true && secondBattleProbe.firstSortieRoleEffects?.fullCoverage === true && secondBattleProbe.firstSortieRoleEffects?.trinityActive === true && secondBattleProbe.firstSortieRoleEffects?.trinitySupportRange === 2, `Expected the second battle to retain its three-role Peach Garden trinity special: ${JSON.stringify(secondBattleProbe.firstSortieRoleEffects)}` ); assert( secondBattleProbe.sortieDoctrine?.roles?.every((role) => role.unitIds.length === 1) && secondBattleProbe.sortieDoctrine?.activeBondCount === 3, `Expected the second battle doctrine to expose one officer per role and all three active bonds: ${JSON.stringify(secondBattleProbe.sortieDoctrine)}` ); assert( secondBattleProbe.openingBannerTexts?.some((text) => text.includes('세 형제 생존 중 공명 지원 거리 2칸')) && secondBattleProbe.openingBannerTexts?.some((text) => text.includes('전술 명령')), `Expected the second battle opening banner to retain trinity and tactical-command guidance: ${JSON.stringify(secondBattleProbe.openingBannerTexts)}` ); const roleSealResyncProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if (!scene) { return undefined; } const officerIds = ['liu-bei', 'guan-yu', 'zhang-fei']; const campaignCurrentKey = 'heros-web:campaign-state'; const campaignSlotKey = `${campaignCurrentKey}:slot-1`; const battleKey = 'heros-web:battle:second-battle-yellow-turban-pursuit'; const storageKeys = [campaignCurrentKey, campaignSlotKey, battleKey, `${battleKey}:slot-1`, 'heros-web:first-battle-state']; const storageSnapshot = new Map(storageKeys.map((key) => [key, window.localStorage.getItem(key)])); const captureUnits = () => { const byId = new Map((window.__HEROS_DEBUG__?.battle()?.units ?? []).map((unit) => [unit.id, unit])); return officerIds.map((id) => { const unit = byId.get(id); return { id, role: unit?.formationRole ?? null, effect: unit?.sortieRoleEffect?.label ?? null, seal: unit?.roleSealText ?? null, sealPresent: unit?.roleSealPresent ?? false, sealVisible: unit?.roleSealVisible ?? false }; }); }; const loadAssignments = (savedCampaignRaw, assignments) => { const campaign = JSON.parse(savedCampaignRaw); campaign.sortieFormationAssignments = assignments; window.localStorage.setItem(campaignSlotKey, JSON.stringify(campaign)); scene.loadBattleState?.(1); }; scene.saveBattleState?.(1); const savedCampaignRaw = window.localStorage.getItem(campaignSlotKey); if (!savedCampaignRaw) { return { error: 'campaign slot save missing' }; } loadAssignments(savedCampaignRaw, { 'liu-bei': 'front', 'guan-yu': 'support', 'zhang-fei': 'reserve' }); const changed = captureUnits(); loadAssignments(savedCampaignRaw, { 'liu-bei': 'reserve', 'guan-yu': 'reserve', 'zhang-fei': 'reserve' }); scene.hideBattleEventBanner?.(); scene.triggeredBattleEvents?.delete('opening'); scene.showOpeningBattleEvent?.(); const allReserve = { doctrineActive: window.__HEROS_DEBUG__?.battle()?.sortieDoctrine?.active ?? false, coveredRoleCount: window.__HEROS_DEBUG__?.battle()?.sortieDoctrine?.coveredRoleCount ?? -1, bannerTexts: (scene.battleEventObjects ?? []) .filter((object) => object?.type === 'Text') .map((object) => object.text) }; scene.hideBattleEventBanner?.(); window.localStorage.setItem(campaignSlotKey, savedCampaignRaw); scene.loadBattleState?.(1); const restored = captureUnits(); storageSnapshot.forEach((value, key) => { if (value === null) { window.localStorage.removeItem(key); } else { window.localStorage.setItem(key, value); } }); return { changed, allReserve, restored }; }); assert( JSON.stringify(roleSealResyncProbe?.changed) === JSON.stringify([ { id: 'liu-bei', role: 'front', effect: '전열 방호', seal: '전', sealPresent: true, sealVisible: true }, { id: 'guan-yu', role: 'support', effect: '후원 지휘', seal: '후', sealPresent: true, sealVisible: true }, { id: 'zhang-fei', role: 'reserve', effect: null, seal: null, sealPresent: false, sealVisible: false } ]), `Expected role seals to resync when a loaded formation changes roles: ${JSON.stringify(roleSealResyncProbe)}` ); assert( roleSealResyncProbe?.allReserve?.doctrineActive === true && roleSealResyncProbe?.allReserve?.coveredRoleCount === 0 && roleSealResyncProbe?.allReserve?.bannerTexts?.some((text) => text.startsWith('출진 군세')), `Expected an all-reserve formation to keep the sortie-doctrine opening banner: ${JSON.stringify(roleSealResyncProbe?.allReserve)}` ); assert( JSON.stringify(roleSealResyncProbe?.restored) === JSON.stringify([ { id: 'liu-bei', role: 'support', effect: '후원 지휘', seal: '후', sealPresent: true, sealVisible: true }, { id: 'guan-yu', role: 'front', effect: '전열 방호', seal: '전', sealPresent: true, sealVisible: true }, { id: 'zhang-fei', role: 'flank', effect: '돌파 선봉', seal: '돌', sealPresent: true, sealVisible: true } ]), `Expected loaded role seals to restore the original formation cleanly: ${JSON.stringify(roleSealResyncProbe?.restored)}` ); await seedCampaignSave(page, { battleId: 'fifty-eighth-battle-qishan-retreat', battleTitle: '기산 후퇴로', step: 'fifty-eighth-camp', gold: 7400, turnNumber: 13, defeatedEnemies: 16, selectedSortieUnitIds: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'zhao-yun', 'huang-quan', 'ma-liang', 'ma-dai'] }); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); await page.mouse.click(962, 310); await waitForCamp(page); await page.screenshot({ path: `${screenshotDir}/rc-mid-campaign-continue.png`, fullPage: true }); await assertCanvasPainted(page, 'mid campaign camp'); const midCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert(midCampState?.campaign?.step === 'fifty-eighth-camp', `Expected mid-campaign save to continue into camp: ${JSON.stringify(midCampState)}`); assert(midCampState.campBattleId === 'fifty-eighth-battle-qishan-retreat', `Expected mid-campaign battle id: ${JSON.stringify(midCampState)}`); assert(midCampState.sortiePlan?.selectedCount > 0, `Expected mid-campaign formation state: ${JSON.stringify(midCampState?.sortiePlan)}`); assert(midCampState.sortieHasBattle === true, `Expected mid-campaign camp to keep a playable sortie target: ${JSON.stringify(midCampState)}`); assert( typeof midCampState.nextSortieBattleId === 'string' && midCampState.nextSortieBattleId.length > 0, `Expected mid-campaign sortie flow to expose a battle id: ${JSON.stringify(midCampState)}` ); assert( midCampState.sortiePlan?.selectedCount <= midCampState.sortiePlan?.maxCount, `Expected mid-campaign sortie selection to stay within limit: ${JSON.stringify(midCampState?.sortiePlan)}` ); assert( midCampState.sortiePlan?.selectedCount === midCampState.sortieDeploymentPreview?.length, `Expected mid-campaign deployment preview to match selected sortie count: ${JSON.stringify(midCampState?.sortiePlan)} / ${JSON.stringify(midCampState?.sortieDeploymentPreview)}` ); assertUnique( midCampState.sortieDeploymentPreview?.map((slot) => slot.unitId), `Expected mid-campaign deployment preview to avoid duplicate units: ${JSON.stringify(midCampState?.sortieDeploymentPreview)}` ); assertUnique( midCampState.sortieDeploymentPreview?.map((slot) => `${slot.x},${slot.y}`), `Expected mid-campaign deployment preview to avoid duplicate tiles: ${JSON.stringify(midCampState?.sortieDeploymentPreview)}` ); const midCampNextBattleId = midCampState.nextSortieBattleId; await page.mouse.click(1160, 38); await waitForSortiePrep(page); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-prep.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp sortie prep'); const midBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( midBriefingState?.stagedSortiePrep === true && midBriefingState?.firstSortiePrep === false && midBriefingState?.sortiePrepStep === 'briefing' && midBriefingState?.sortiePrimaryAction?.kind === 'next' && midBriefingState?.sortiePrimaryAction?.nextStep === 'formation', `Expected a mid-campaign battle sortie to open at the staged briefing: ${JSON.stringify(midBriefingState)}` ); assert( midBriefingState?.sortiePortraitRosterView?.enabled === true && midBriefingState?.sortiePortraitRosterView?.total > 8 && midBriefingState?.sortiePortraitRosterView?.visibleCount === 8 && midBriefingState?.sortiePortraitRosterView?.start === 1 && midBriefingState?.sortiePortraitRosterView?.end === 8, `Expected the mid-campaign staged formation to expose an eight-card portrait roster window: ${JSON.stringify(midBriefingState?.sortiePortraitRosterView)}` ); await advanceSortiePrepStep(page, 'formation'); const midFormationSwapFixture = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const state = window.__HEROS_DEBUG__?.camp(); const preservedUnitId = state?.selectedSortieUnitIds?.find((unitId) => unitId !== 'ma-dai'); if (!scene || !preservedUnitId || typeof scene.persistSortieSelection !== 'function' || typeof scene.showSortiePrep !== 'function') { return { ready: false, preservedUnitId: preservedUnitId ?? null }; } scene.sortieFormationAssignments = { ...scene.sortieFormationAssignments, 'ma-dai': 'flank' }; scene.sortieItemAssignments = { ...scene.sortieItemAssignments, 'ma-dai': { ...(scene.sortieItemAssignments?.['ma-dai'] ?? {}), bean: 1 }, [preservedUnitId]: { ...(scene.sortieItemAssignments?.[preservedUnitId] ?? {}), salve: 1 } }; scene.persistSortieSelection(); scene.showSortiePrep(); scene.persistSortieSelection(); scene.showSortiePrep(); return { ready: true, preservedUnitId }; }); assert( midFormationSwapFixture.ready === true && midFormationSwapFixture.preservedUnitId, `Expected a saved role and supply fixture for the swap regression: ${JSON.stringify(midFormationSwapFixture)}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp sortie formation'); const midFormationPortraitState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const missingMidFormationPortraits = midFormationPortraitState?.sortiePortraitRoster?.filter( (unit) => !unit.portraitReady || !unit.portraitTextureKey?.startsWith('portrait-card-') ) ?? []; assert( midFormationPortraitState?.sortiePortraitRoster?.length === 23 && missingMidFormationPortraits.length === 0, `Expected all 23 campaign officers to use loaded lightweight portrait cards: ${JSON.stringify(missingMidFormationPortraits)}` ); const midFormationSelectedBefore = [...(midFormationPortraitState?.selectedSortieUnitIds ?? [])]; const midFormationCampaignSelectedBefore = [...(midFormationPortraitState?.campaign?.selectedSortieUnitIds ?? [])]; const midFormationAssignmentsBefore = { ...(midFormationPortraitState?.sortieFormationAssignments ?? {}) }; const midFormationItemAssignmentsBefore = structuredClone(midFormationPortraitState?.sortieItemAssignments ?? {}); const midFormationSaveBefore = await readCampaignSave(page); assert( midFormationAssignmentsBefore['ma-dai'] === 'flank' && midFormationItemAssignmentsBefore['ma-dai']?.bean === 1 && midFormationItemAssignmentsBefore[midFormationSwapFixture.preservedUnitId]?.salve === 1, `Expected the swap fixture to retain Ma Dai and another officer's assignments: ${JSON.stringify({ formation: midFormationAssignmentsBefore, items: midFormationItemAssignmentsBefore, preservedUnitId: midFormationSwapFixture.preservedUnitId })}` ); await page.mouse.click(210, 447); await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieFocusedUnitId === 'ma-dai', undefined, { timeout: 30000 }); const swapFocusState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( JSON.stringify(swapFocusState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore) && JSON.stringify(swapFocusState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore), `Expected focusing Ma Dai to preserve the actual sortie roster: ${JSON.stringify(swapFocusState?.selectedSortieUnitIds)}` ); await page.mouse.move(530, 544); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortieHoveredUnitId === 'guan-yu' && state?.sortieComparisonPreview?.source === 'hover-swap'; }, undefined, { timeout: 30000 }); const midFormationSwapPreviewProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const view = scene?.sortieComparisonPanelView; return { state: window.__HEROS_DEBUG__?.camp(), panel: { title: view?.title?.text ?? null, headline: view?.headline?.text ?? null, impact: view?.impact?.text ?? null, detail: view?.detail?.text ?? null } }; }); const midFormationSwapPreviewState = midFormationSwapPreviewProbe.state; const swapPreview = midFormationSwapPreviewState?.sortieComparisonPreview; const projectedSwapIdSet = new Set( midFormationSelectedBefore.map((unitId) => unitId === 'ma-dai' ? 'guan-yu' : unitId) ); const expectedProjectedSwapIds = [ ...(midFormationSwapPreviewState?.sortieRoster ?? []) .filter((unit) => unit.required && projectedSwapIdSet.has(unit.id)) .map((unit) => unit.id), ...(midFormationSwapPreviewState?.sortieRoster ?? []) .filter((unit) => !unit.required && projectedSwapIdSet.has(unit.id)) .map((unit) => unit.id) ].slice(0, midFormationSelectedBefore.length); assert( midFormationPortraitState.sortiePortraitRoster.some( (unit) => unit.id === swapPreview?.incomingUnitId && unit.portraitReady && unit.portraitTextureKey?.startsWith('portrait-card-') ), `Expected portrait-card hover feedback for a mapped swap candidate: ${JSON.stringify(swapPreview?.incomingUnitId)}` ); assert( swapPreview?.mode === 'swap' && swapPreview?.incomingUnitId === 'guan-yu' && swapPreview?.outgoingUnitId === 'ma-dai' && JSON.stringify(swapPreview?.selectedUnitIds) === JSON.stringify(midFormationSelectedBefore) && JSON.stringify(swapPreview?.projectedSelectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) && JSON.stringify(swapPreview?.current?.selectedUnitIds) === JSON.stringify(midFormationSelectedBefore) && JSON.stringify(swapPreview?.projected?.selectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) && JSON.stringify(swapPreview?.comparison?.current?.selectedUnitIds) === JSON.stringify(midFormationSelectedBefore) && JSON.stringify(swapPreview?.comparison?.projected?.selectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) && swapPreview?.metrics?.length === 4 && swapPreview.metrics.some((metric) => metric.delta !== 0) && swapPreview?.comparison?.lostRoles?.includes('flank') && swapPreview?.comparison?.activeBondDelta === 0 && swapPreview?.comparison?.gainedBonds?.length > 0 && swapPreview?.comparison?.lostBonds?.length > 0, `Expected a meaningful non-destructive Ma Dai to Guan Yu swap preview: ${JSON.stringify(swapPreview)}` ); assert( midFormationSwapPreviewProbe.panel.title === '가상 교체 · 마대 → 관우' && midFormationSwapPreviewProbe.panel.headline?.includes('OUT 마대') && midFormationSwapPreviewProbe.panel.headline?.includes('IN 관우') && midFormationSwapPreviewProbe.panel.impact?.includes('공백 돌파') && midFormationSwapPreviewProbe.panel.detail?.includes('+맥성 북문') && midFormationSwapPreviewProbe.panel.detail?.includes('-산길과 말발굽') && midFormationSwapPreviewProbe.panel.detail?.includes('실제 편성 미변경'), `Expected the visible comparison panel to render the swap preview: ${JSON.stringify(midFormationSwapPreviewProbe.panel)}` ); assert( JSON.stringify(midFormationSwapPreviewState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore) && JSON.stringify(midFormationSwapPreviewState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore), `Expected hover swap preview to preserve runtime and saved selections: ${JSON.stringify(midFormationSwapPreviewState?.selectedSortieUnitIds)}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation-hover.png`, fullPage: true }); await page.mouse.move(40, 710); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortieHoveredUnitId === null && state?.sortieComparisonPreview?.source === 'focus'; }, undefined, { timeout: 30000 }); const afterSwapPreviewProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const view = scene?.sortieComparisonPanelView; return { state: window.__HEROS_DEBUG__?.camp(), panel: { title: view?.title?.text ?? null, headline: view?.headline?.text ?? null } }; }); const afterSwapPreviewState = afterSwapPreviewProbe.state; assert( JSON.stringify(afterSwapPreviewState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore) && JSON.stringify(afterSwapPreviewState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore), `Expected pointerout to restore focus preview without mutating selections: ${JSON.stringify(afterSwapPreviewState?.selectedSortieUnitIds)}` ); assert( afterSwapPreviewState?.sortieComparisonPreview?.source === 'focus' && afterSwapPreviewState?.sortieComparisonPreview?.mode === 'remove' && afterSwapPreviewState?.sortieComparisonPreview?.unitId === 'ma-dai' && afterSwapPreviewState?.sortieComparisonPreview?.incomingUnitId === null && afterSwapPreviewState?.sortieComparisonPreview?.outgoingUnitId === 'ma-dai' && afterSwapPreviewProbe.panel.title === '편성 변화 · 마대' && !afterSwapPreviewProbe.panel.headline?.includes('관우'), `Expected pointerout to restore the visible Ma Dai focus preview: ${JSON.stringify(afterSwapPreviewProbe)}` ); await page.mouse.move(530, 544); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortieHoveredUnitId === 'guan-yu' && state?.sortieComparisonPreview?.source === 'hover-swap'; }, undefined, { timeout: 30000 }); await page.mouse.click(530, 544); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortiePinnedSwapCandidateUnitId === 'guan-yu' && state?.sortieComparisonPreview?.source === 'pinned-swap' && state?.sortieComparisonAction?.kind === 'confirm-swap'; }, undefined, { timeout: 30000 }); const pinnedSwapProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const state = window.__HEROS_DEBUG__?.camp(); const view = scene?.sortieComparisonPanelView; return { state, action: { buttonVisible: view?.actionButton?.visible ?? false, labelVisible: view?.actionLabel?.visible ?? false, label: view?.actionLabel?.text ?? null } }; }); const pinnedSwapState = pinnedSwapProbe.state; const pinnedSwapPreview = pinnedSwapState?.sortieComparisonPreview; const pinnedSwapSave = await readCampaignSave(page); const previewIncomingRole = pinnedSwapState?.sortieRoster?.find((unit) => unit.id === 'guan-yu')?.formationRole; assert( pinnedSwapPreview?.source === 'pinned-swap' && pinnedSwapPreview?.mode === 'swap' && pinnedSwapPreview?.outgoingUnitId === 'ma-dai' && pinnedSwapPreview?.incomingUnitId === 'guan-yu' && JSON.stringify(pinnedSwapPreview?.projectedSelectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) && previewIncomingRole === 'front' && pinnedSwapState?.sortieComparisonAction?.label === '교체 확정' && pinnedSwapState?.sortieComparisonAction?.outgoingUnitId === 'ma-dai' && pinnedSwapState?.sortieComparisonAction?.incomingUnitId === 'guan-yu' && pinnedSwapProbe.action.buttonVisible === true && pinnedSwapProbe.action.labelVisible === true && pinnedSwapProbe.action.label === '교체 확정', `Expected clicking Guan Yu's card body to pin the canonical Ma Dai swap with a visible confirm action: ${JSON.stringify(pinnedSwapProbe)}` ); assert( JSON.stringify(pinnedSwapState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore) && JSON.stringify(pinnedSwapState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore) && sameJsonValue(pinnedSwapState?.sortieFormationAssignments, midFormationAssignmentsBefore) && sameJsonValue(pinnedSwapState?.sortieItemAssignments, midFormationItemAssignmentsBefore) && JSON.stringify(pinnedSwapSave.current?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.current?.selectedSortieUnitIds) && JSON.stringify(pinnedSwapSave.slot1?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.slot1?.selectedSortieUnitIds) && sameJsonValue(pinnedSwapSave.current?.sortieFormationAssignments, midFormationSaveBefore.current?.sortieFormationAssignments) && sameJsonValue(pinnedSwapSave.slot1?.sortieFormationAssignments, midFormationSaveBefore.slot1?.sortieFormationAssignments) && sameJsonValue(pinnedSwapSave.current?.sortieItemAssignments, midFormationSaveBefore.current?.sortieItemAssignments) && sameJsonValue(pinnedSwapSave.slot1?.sortieItemAssignments, midFormationSaveBefore.slot1?.sortieItemAssignments), `Expected pinning the swap to preserve runtime, campaign, current-save, and slot-1 assignments: ${JSON.stringify({ state: pinnedSwapState, save: pinnedSwapSave })}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-swap-pinned.png`, fullPage: true }); const confirmSwapActionPoint = await readSortieComparisonActionPoint(page); assert( confirmSwapActionPoint.label === '교체 확정', `Expected the scaled Phaser action button to expose the confirm label: ${JSON.stringify(confirmSwapActionPoint)}` ); await page.mouse.click(confirmSwapActionPoint.x, confirmSwapActionPoint.y); await page.waitForFunction((projectedIds) => { const state = window.__HEROS_DEBUG__?.camp(); return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(projectedIds) && state?.sortieFormationAssignments?.['ma-dai'] === undefined && state?.sortieFormationAssignments?.['guan-yu'] === 'front' && state?.sortieItemAssignments?.['ma-dai'] === undefined && state?.sortieItemAssignments?.['guan-yu'] === undefined && state?.sortieSwapUndo?.available === true && state?.sortieComparisonAction?.kind === 'undo-swap'; }, expectedProjectedSwapIds, { timeout: 30000 }); const confirmedSwapState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const confirmedSwapSave = await readCampaignSave(page); const expectedConfirmedAssignments = { ...midFormationAssignmentsBefore }; delete expectedConfirmedAssignments['ma-dai']; expectedConfirmedAssignments['guan-yu'] = previewIncomingRole; const expectedConfirmedItemAssignments = structuredClone(midFormationItemAssignmentsBefore); delete expectedConfirmedItemAssignments['ma-dai']; delete expectedConfirmedItemAssignments['guan-yu']; assert( JSON.stringify(confirmedSwapState?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds) && JSON.stringify(confirmedSwapState?.campaign?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds) && JSON.stringify(confirmedSwapSave.current?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds) && JSON.stringify(confirmedSwapSave.slot1?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds), `Expected swap confirmation to persist the canonical projected roster everywhere: ${JSON.stringify({ expectedProjectedSwapIds, runtime: confirmedSwapState?.selectedSortieUnitIds, campaign: confirmedSwapState?.campaign?.selectedSortieUnitIds, current: confirmedSwapSave.current?.selectedSortieUnitIds, slot1: confirmedSwapSave.slot1?.selectedSortieUnitIds })}` ); assert( sameJsonValue(confirmedSwapState?.sortieFormationAssignments, expectedConfirmedAssignments) && sameJsonValue(confirmedSwapState?.campaign?.sortieFormationAssignments, expectedConfirmedAssignments) && sameJsonValue(confirmedSwapSave.current?.sortieFormationAssignments, expectedConfirmedAssignments) && sameJsonValue(confirmedSwapSave.slot1?.sortieFormationAssignments, expectedConfirmedAssignments) && sameJsonValue(confirmedSwapState?.sortieItemAssignments, expectedConfirmedItemAssignments) && sameJsonValue(confirmedSwapState?.campaign?.sortieItemAssignments, expectedConfirmedItemAssignments) && sameJsonValue(confirmedSwapSave.current?.sortieItemAssignments, expectedConfirmedItemAssignments) && sameJsonValue(confirmedSwapSave.slot1?.sortieItemAssignments, expectedConfirmedItemAssignments), `Expected confirmation to remove Ma Dai's role and supply, assign Guan Yu's preview role without transferring supply, and preserve every other assignment: ${JSON.stringify({ expectedFormation: expectedConfirmedAssignments, expectedItems: expectedConfirmedItemAssignments, state: confirmedSwapState, save: confirmedSwapSave })}` ); assert( confirmedSwapState?.sortieFocusedUnitId === 'guan-yu' && confirmedSwapState?.sortiePinnedSwapCandidateUnitId === null && confirmedSwapState?.sortieSwapUndo?.available === true && confirmedSwapState?.sortieSwapUndo?.outgoingUnitId === 'ma-dai' && confirmedSwapState?.sortieSwapUndo?.incomingUnitId === 'guan-yu' && confirmedSwapState?.sortieComparisonAction?.kind === 'undo-swap' && confirmedSwapState?.sortieComparisonAction?.label === '되돌리기' && confirmedSwapState?.sortiePlanFeedback?.includes('장비·보급'), `Expected confirmed swap feedback and one-use undo action: ${JSON.stringify(confirmedSwapState)}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-swap-confirmed.png`, fullPage: true }); const openPresetBookForSavePoint = await readSortiePresetControlPoint(page, 'toggle'); await page.mouse.click(openPresetBookForSavePoint.x, openPresetBookForSavePoint.y); await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortiePresetBrowser?.open === true, undefined, { timeout: 30000 }); const emptyPresetBookState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( emptyPresetBookState?.sortiePresetBrowser?.cards?.length === 3 && emptyPresetBookState.sortiePresetBrowser.cards.every((card) => card.saved === false) && emptyPresetBookState?.sortieSwapUndo?.available === true, `Expected three empty preset cards without consuming the pending swap undo: ${JSON.stringify(emptyPresetBookState?.sortiePresetBrowser)}` ); const saveMobilePresetPoint = await readSortiePresetControlPoint(page, 'save', 'mobile'); await page.mouse.click(saveMobilePresetPoint.x, saveMobilePresetPoint.y); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortieFormationPresets?.mobile?.selectedUnitIds?.length > 0 && state?.sortieSwapUndo?.available === true && state?.sortieComparisonAction?.kind === 'undo-swap'; }, undefined, { timeout: 30000 }); const savedMobilePresetState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const savedMobilePresetSave = await readCampaignSave(page); const savedMobilePreset = savedMobilePresetState?.sortieFormationPresets?.mobile; assert( JSON.stringify(savedMobilePreset?.selectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) && savedMobilePreset?.selectedUnitIds?.every((unitId) => Boolean(savedMobilePreset.formationAssignments?.[unitId])) && Object.keys(savedMobilePreset ?? {}).sort().join(',') === 'formationAssignments,selectedUnitIds' && savedMobilePreset?.sortieItemAssignments === undefined && savedMobilePreset?.itemAssignments === undefined, `Expected mobile preset save to materialize every selected role without storing supplies: ${JSON.stringify(savedMobilePreset)}` ); assert( sameJsonValue(savedMobilePresetSave.current?.sortieFormationPresets?.mobile, savedMobilePreset) && sameJsonValue(savedMobilePresetSave.slot1?.sortieFormationPresets?.mobile, savedMobilePreset) && sameJsonValue(savedMobilePresetState?.sortieItemAssignments, expectedConfirmedItemAssignments) && sameJsonValue(savedMobilePresetState?.campaign?.sortieItemAssignments, expectedConfirmedItemAssignments) && savedMobilePresetState?.sortieSwapUndo?.available === true, `Expected preset metadata to sync to current and slot saves without changing the applied roster, roles, supplies, or swap undo: ${JSON.stringify({ state: savedMobilePresetState, save: savedMobilePresetSave })}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-preset-saved.png`, fullPage: true }); const undoSwapActionPoint = await readSortieComparisonActionPoint(page); assert( undoSwapActionPoint.label === '되돌리기', `Expected the scaled Phaser action button to switch to undo: ${JSON.stringify(undoSwapActionPoint)}` ); await page.mouse.click(undoSwapActionPoint.x, undoSwapActionPoint.y); await page.waitForFunction((selectedBefore) => { const state = window.__HEROS_DEBUG__?.camp(); return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(selectedBefore) && state?.sortieFocusedUnitId === 'ma-dai' && state?.sortieComparisonAction === null && !state?.sortieSwapUndo?.available; }, midFormationSelectedBefore, { timeout: 30000 }); const undoneSwapProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const state = window.__HEROS_DEBUG__?.camp(); const view = scene?.sortieComparisonPanelView; return { state, action: { buttonVisible: view?.actionButton?.visible ?? false, labelVisible: view?.actionLabel?.visible ?? false, label: view?.actionLabel?.text ?? null } }; }); const undoneSwapState = undoneSwapProbe.state; const undoneSwapSave = await readCampaignSave(page); const undoRestoreChecks = { runtimeSelection: JSON.stringify(undoneSwapState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore), campaignSelection: JSON.stringify(undoneSwapState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore), currentSaveSelection: JSON.stringify(undoneSwapSave.current?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.current?.selectedSortieUnitIds), slotSelection: JSON.stringify(undoneSwapSave.slot1?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.slot1?.selectedSortieUnitIds), runtimeFormation: sameJsonValue(undoneSwapState?.sortieFormationAssignments, midFormationAssignmentsBefore), campaignFormation: sameJsonValue(undoneSwapState?.campaign?.sortieFormationAssignments, midFormationAssignmentsBefore), currentSaveFormation: sameJsonValue(undoneSwapSave.current?.sortieFormationAssignments, midFormationSaveBefore.current?.sortieFormationAssignments), slotFormation: sameJsonValue(undoneSwapSave.slot1?.sortieFormationAssignments, midFormationSaveBefore.slot1?.sortieFormationAssignments), runtimeItems: sameJsonValue(undoneSwapState?.sortieItemAssignments, midFormationItemAssignmentsBefore), campaignItems: sameJsonValue(undoneSwapState?.campaign?.sortieItemAssignments, midFormationItemAssignmentsBefore), currentSaveItems: sameJsonValue(undoneSwapSave.current?.sortieItemAssignments, midFormationSaveBefore.current?.sortieItemAssignments), slotItems: sameJsonValue(undoneSwapSave.slot1?.sortieItemAssignments, midFormationSaveBefore.slot1?.sortieItemAssignments) }; assert( Object.values(undoRestoreChecks).every(Boolean), `Expected one-time undo to restore the exact roster, role map, and supply map in runtime and both saves: ${JSON.stringify({ checks: undoRestoreChecks, expected: { runtimeSelected: midFormationSelectedBefore, campaignSelected: midFormationCampaignSelectedBefore, currentSaveSelected: midFormationSaveBefore.current?.selectedSortieUnitIds, slotSelected: midFormationSaveBefore.slot1?.selectedSortieUnitIds, formation: midFormationAssignmentsBefore, items: midFormationItemAssignmentsBefore }, actual: { runtimeSelected: undoneSwapState?.selectedSortieUnitIds, campaignSelected: undoneSwapState?.campaign?.selectedSortieUnitIds, currentSaveSelected: undoneSwapSave.current?.selectedSortieUnitIds, slotSelected: undoneSwapSave.slot1?.selectedSortieUnitIds, runtimeFormation: undoneSwapState?.sortieFormationAssignments, campaignFormation: undoneSwapState?.campaign?.sortieFormationAssignments, currentSaveFormation: undoneSwapSave.current?.sortieFormationAssignments, slotFormation: undoneSwapSave.slot1?.sortieFormationAssignments, runtimeItems: undoneSwapState?.sortieItemAssignments, campaignItems: undoneSwapState?.campaign?.sortieItemAssignments, currentSaveItems: undoneSwapSave.current?.sortieItemAssignments, slotItems: undoneSwapSave.slot1?.sortieItemAssignments } })}` ); assert( undoneSwapState?.sortieFocusedUnitId === 'ma-dai' && undoneSwapState?.sortiePinnedSwapCandidateUnitId === null && undoneSwapState?.sortieComparisonAction === null && !undoneSwapState?.sortieSwapUndo?.available && undoneSwapProbe.action.buttonVisible === false && undoneSwapProbe.action.labelVisible === false, `Expected undo to restore Ma Dai focus and consume the panel action exactly once: ${JSON.stringify(undoneSwapProbe)}` ); const presetBaselineState = undoneSwapState; const presetBaselineSave = undoneSwapSave; const openPresetBookForApplyPoint = await readSortiePresetControlPoint(page, 'toggle'); await page.mouse.click(openPresetBookForApplyPoint.x, openPresetBookForApplyPoint.y); await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortiePresetBrowser?.open === true, undefined, { timeout: 30000 }); const presetBookState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const mobilePresetCard = presetBookState?.sortiePresetBrowser?.cards?.find((card) => card.id === 'mobile'); assert( presetBookState?.sortiePresetBrowser?.cards?.length === 3 && presetBookState?.sortiePresetBrowser?.recommendedPresetId === 'mobile' && mobilePresetCard?.saved === true && mobilePresetCard?.applicable === true && mobilePresetCard?.recommended === true && mobilePresetCard?.current === false && presetBookState.sortiePresetBrowser.cards.filter((card) => !card.saved).length === 2, `Expected the only saved valid preset to receive the scenario recommendation badge: ${JSON.stringify(presetBookState?.sortiePresetBrowser)}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-preset-book.png`, fullPage: true }); const hoverMobilePresetPoint = await readSortiePresetControlPoint(page, 'card', 'mobile'); await page.mouse.move(hoverMobilePresetPoint.x, hoverMobilePresetPoint.y); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortiePresetBrowser?.hoveredPresetId === 'mobile' && state?.sortieComparisonPreview?.source === 'hover-preset' && state?.sortieComparisonPreview?.presetId === 'mobile'; }, undefined, { timeout: 30000 }); const hoverPresetProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const view = scene?.sortieComparisonPanelView; return { state: window.__HEROS_DEBUG__?.camp(), panel: { title: view?.title?.text ?? null, headline: view?.headline?.text ?? null, detail: view?.detail?.text ?? null } }; }); const hoverPresetState = hoverPresetProbe.state; const hoverPresetSave = await readCampaignSave(page); const hoverProjectionCard = hoverPresetState?.sortiePresetBrowser?.cards?.find((card) => card.id === 'mobile'); assert( hoverPresetState?.sortieComparisonPreview?.mode === 'preset' && hoverPresetState?.sortieComparisonPreview?.metrics?.length === 4 && hoverPresetState.sortieComparisonPreview.metrics[0]?.label === '공격 합' && hoverPresetState.sortieComparisonPreview.metrics[1]?.label === '지력 합' && hoverPresetState.sortieComparisonPreview.metrics[2]?.label === '평균 기동' && hoverPresetProbe.panel.title === '기동 편성책 비교' && hoverPresetProbe.panel.headline?.includes('유지') && hoverPresetProbe.panel.detail?.includes('실제 편성 미변경'), `Expected a whole-formation, non-destructive preset comparison: ${JSON.stringify(hoverPresetProbe)}` ); assert( JSON.stringify(hoverPresetState?.selectedSortieUnitIds) === JSON.stringify(presetBaselineState?.selectedSortieUnitIds) && sameJsonValue(hoverPresetState?.sortieFormationAssignments, presetBaselineState?.sortieFormationAssignments) && sameJsonValue(hoverPresetState?.sortieItemAssignments, presetBaselineState?.sortieItemAssignments) && sameJsonValue(hoverPresetSave.current, presetBaselineSave.current) && sameJsonValue(hoverPresetSave.slot1, presetBaselineSave.slot1), `Expected preset hover to preserve runtime, campaign, current save, and slot save: ${JSON.stringify(hoverPresetState)}` ); const compareMobilePresetPoint = await readSortiePresetControlPoint(page, 'compare', 'mobile'); await page.mouse.click(compareMobilePresetPoint.x, compareMobilePresetPoint.y); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortiePresetBrowser?.pinnedPresetId === 'mobile' && state?.sortieComparisonPreview?.source === 'pinned-preset' && state?.sortieComparisonAction?.kind === 'confirm-preset'; }, undefined, { timeout: 30000 }); const pinnedPresetState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const pinnedPresetSave = await readCampaignSave(page); const pinnedProjectionCard = pinnedPresetState?.sortiePresetBrowser?.cards?.find((card) => card.id === 'mobile'); assert( JSON.stringify(pinnedProjectionCard?.projectedSelectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) && sameJsonValue(pinnedProjectionCard?.projectedFormationAssignments, savedMobilePreset?.formationAssignments) && pinnedPresetState?.sortieComparisonAction?.label === '적용 확정' && sameJsonValue(pinnedPresetSave.current, presetBaselineSave.current) && sameJsonValue(pinnedPresetSave.slot1, presetBaselineSave.slot1), `Expected pinning the preset to keep every saved value unchanged until confirmation: ${JSON.stringify(pinnedPresetState)}` ); const expectedPresetSelectedIds = [...(pinnedProjectionCard?.projectedSelectedUnitIds ?? [])]; const expectedPresetFormationAssignments = { ...(pinnedProjectionCard?.projectedFormationAssignments ?? {}) }; const expectedPresetItemAssignments = structuredClone(pinnedProjectionCard?.projectedItemAssignments ?? {}); const applyPresetActionPoint = await readSortieComparisonActionPoint(page); assert(applyPresetActionPoint.label === '적용 확정', `Expected preset confirmation label: ${JSON.stringify(applyPresetActionPoint)}`); await page.mouse.click(applyPresetActionPoint.x, applyPresetActionPoint.y); await page.waitForFunction((expectedIds) => { const state = window.__HEROS_DEBUG__?.camp(); return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(expectedIds) && state?.sortiePresetUndo?.available === true && state?.sortieComparisonAction?.kind === 'undo-preset' && state?.sortiePresetBrowser?.open === false; }, expectedPresetSelectedIds, { timeout: 30000 }); const appliedPresetState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const appliedPresetSave = await readCampaignSave(page); assert( JSON.stringify(appliedPresetState?.selectedSortieUnitIds) === JSON.stringify(expectedPresetSelectedIds) && JSON.stringify(appliedPresetState?.campaign?.selectedSortieUnitIds) === JSON.stringify(expectedPresetSelectedIds) && JSON.stringify(appliedPresetSave.current?.selectedSortieUnitIds) === JSON.stringify(expectedPresetSelectedIds) && JSON.stringify(appliedPresetSave.slot1?.selectedSortieUnitIds) === JSON.stringify(expectedPresetSelectedIds) && sameJsonValue(appliedPresetState?.sortieFormationAssignments, expectedPresetFormationAssignments) && sameJsonValue(appliedPresetState?.campaign?.sortieFormationAssignments, expectedPresetFormationAssignments) && sameJsonValue(appliedPresetSave.current?.sortieFormationAssignments, expectedPresetFormationAssignments) && sameJsonValue(appliedPresetSave.slot1?.sortieFormationAssignments, expectedPresetFormationAssignments), `Expected preset confirmation to persist the canonical roster and stored roles everywhere: ${JSON.stringify(appliedPresetState)}` ); assert( sameJsonValue(appliedPresetState?.sortieItemAssignments, expectedPresetItemAssignments) && sameJsonValue(appliedPresetState?.campaign?.sortieItemAssignments, expectedPresetItemAssignments) && sameJsonValue(appliedPresetSave.current?.sortieItemAssignments, expectedPresetItemAssignments) && sameJsonValue(appliedPresetSave.slot1?.sortieItemAssignments, expectedPresetItemAssignments) && appliedPresetState?.sortieItemAssignments?.['ma-dai'] === undefined && appliedPresetState?.sortieItemAssignments?.['guan-yu'] === undefined && appliedPresetState?.sortiePresetUndo?.presetId === 'mobile', `Expected preset apply to preserve only shared officers' supplies without auto-transferring them: ${JSON.stringify({ expected: expectedPresetItemAssignments, actual: appliedPresetState?.sortieItemAssignments })}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-preset-applied.png`, fullPage: true }); const undoPresetActionPoint = await readSortieComparisonActionPoint(page); assert(undoPresetActionPoint.label === '되돌리기', `Expected preset undo label: ${JSON.stringify(undoPresetActionPoint)}`); await page.mouse.click(undoPresetActionPoint.x, undoPresetActionPoint.y); await page.waitForFunction((baselineIds) => { const state = window.__HEROS_DEBUG__?.camp(); return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(baselineIds) && state?.sortiePresetUndo === null && state?.sortieComparisonAction === null; }, presetBaselineState?.selectedSortieUnitIds ?? [], { timeout: 30000 }); const undonePresetState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const undonePresetSave = await readCampaignSave(page); assert( JSON.stringify(undonePresetState?.selectedSortieUnitIds) === JSON.stringify(presetBaselineState?.selectedSortieUnitIds) && sameJsonValue(undonePresetState?.sortieFormationAssignments, presetBaselineState?.sortieFormationAssignments) && sameJsonValue(undonePresetState?.sortieItemAssignments, presetBaselineState?.sortieItemAssignments) && JSON.stringify(undonePresetSave.current?.selectedSortieUnitIds) === JSON.stringify(presetBaselineSave.current?.selectedSortieUnitIds) && JSON.stringify(undonePresetSave.slot1?.selectedSortieUnitIds) === JSON.stringify(presetBaselineSave.slot1?.selectedSortieUnitIds) && sameJsonValue(undonePresetSave.current?.sortieFormationAssignments, presetBaselineSave.current?.sortieFormationAssignments) && sameJsonValue(undonePresetSave.slot1?.sortieFormationAssignments, presetBaselineSave.slot1?.sortieFormationAssignments) && sameJsonValue(undonePresetSave.current?.sortieItemAssignments, presetBaselineSave.current?.sortieItemAssignments) && sameJsonValue(undonePresetSave.slot1?.sortieItemAssignments, presetBaselineSave.slot1?.sortieItemAssignments) && sameJsonValue(undonePresetSave.current?.sortieFormationPresets?.mobile, savedMobilePreset) && sameJsonValue(undonePresetSave.slot1?.sortieFormationPresets?.mobile, savedMobilePreset), `Expected one-time preset undo to restore the exact roster, roles, and supplies while retaining the saved preset: ${JSON.stringify({ state: undonePresetState, save: undonePresetSave })}` ); const midFormationFirstPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); await page.mouse.click(658, 196); await page.waitForFunction((previousScroll) => { const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView; return view?.scroll > previousScroll; }, midFormationFirstPage?.scroll ?? 0, { timeout: 30000 }); const midFormationSecondPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); assert( midFormationSecondPage?.start > 1 && midFormationSecondPage?.end > 8, `Expected the portrait roster next-page control to reveal later officers: ${JSON.stringify(midFormationSecondPage)}` ); await page.mouse.click(610, 196); await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView?.scroll === 0, undefined, { timeout: 30000 }); const fullRosterCandidateProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const state = window.__HEROS_DEBUG__?.camp(); const candidate = state?.sortieRoster?.find((unit) => !unit.selected && unit.available); if (!candidate || typeof scene?.toggleSortieUnit !== 'function') { return { candidateId: null, selectedBefore: state?.selectedSortieUnitIds ?? [] }; } scene.toggleSortieUnit(candidate.id); return { candidateId: candidate.id, selectedBefore: state.selectedSortieUnitIds ?? [] }; }); assert(fullRosterCandidateProbe.candidateId, `Expected a full-roster swap candidate: ${JSON.stringify(fullRosterCandidateProbe)}`); await page.waitForFunction((probe) => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortieFocusedUnitId === probe.candidateId && state?.sortiePrepStep === 'formation' && state?.sortieFocusedSynergyPreview?.mode === 'waiting' && state?.selectedSortieUnitIds?.length === probe.selectedBefore.length && !state.selectedSortieUnitIds.includes(probe.candidateId); }, fullRosterCandidateProbe, { timeout: 30000 }); const underCapacityProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const state = window.__HEROS_DEBUG__?.camp(); const removable = state?.sortieRoster?.find((unit) => unit.selected && !unit.required); if (!removable || typeof scene?.toggleSortieUnit !== 'function') { return { removedUnitId: null, selectedBefore: state?.selectedSortieUnitIds ?? [] }; } scene.toggleSortieUnit(removable.id); return { removedUnitId: removable.id, selectedBefore: state.selectedSortieUnitIds ?? [] }; }); assert(underCapacityProbe.removedUnitId, `Expected a removable mid-campaign officer: ${JSON.stringify(underCapacityProbe)}`); await page.waitForFunction((probe) => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortieVisible === true && state?.sortiePrepStep === 'formation' && state?.selectedSortieUnitIds?.length === probe.selectedBefore.length - 1 && !state.selectedSortieUnitIds.includes(probe.removedUnitId) && state?.sortieFocusedSynergyPreview?.mode === 'add'; }, underCapacityProbe, { timeout: 30000 }); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); await page.mouse.click(962, 310); await waitForCamp(page); const restoredUnderCapacityState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( restoredUnderCapacityState?.selectedSortieUnitIds?.length === underCapacityProbe.selectedBefore.length - 1 && !restoredUnderCapacityState.selectedSortieUnitIds.includes(underCapacityProbe.removedUnitId), `Expected an under-capacity manual formation to survive scene recreation: ${JSON.stringify(restoredUnderCapacityState?.selectedSortieUnitIds)} / ${JSON.stringify(underCapacityProbe)}` ); const reserveDoctrineSetup = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const state = window.__HEROS_DEBUG__?.camp(); const supportUnitIds = (state?.sortieRoster ?? []) .filter((unit) => unit.selected && unit.formationRole === 'support') .map((unit) => unit.id); scene.sortieFormationAssignments = { ...scene.sortieFormationAssignments, ...Object.fromEntries(supportUnitIds.map((unitId) => [unitId, 'reserve'])) }; scene.persistSortieSelection(); return { supportUnitIds, state: window.__HEROS_DEBUG__?.camp() }; }); assert( reserveDoctrineSetup.supportUnitIds.length > 0 && reserveDoctrineSetup.state?.sortiePlan?.formationCoverageComplete === false, `Expected the mid-campaign doctrine setup to leave support uncovered with reserve officers: ${JSON.stringify(reserveDoctrineSetup)}` ); const midCampSelectedSortieUnitIds = reserveDoctrineSetup.state?.selectedSortieUnitIds ?? []; const midCampDeploymentPreview = reserveDoctrineSetup.state?.sortieDeploymentPreview ?? []; const midCampFormationAssignments = reserveDoctrineSetup.state?.sortieFormationAssignments ?? {}; const midCampItemAssignments = reserveDoctrineSetup.state?.sortieItemAssignments ?? {}; await page.mouse.click(1160, 38); await waitForSortiePrep(page); const restoredMidBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( restoredMidBriefingState?.stagedSortiePrep === true && restoredMidBriefingState?.sortiePrepStep === 'briefing', `Expected reopening the mid-campaign sortie to restart at briefing: ${JSON.stringify(restoredMidBriefingState)}` ); const restoredMidFormationState = await advanceSortiePrepStep(page, 'formation'); assert( restoredMidFormationState?.sortiePlan?.formationCoverageComplete === false && JSON.stringify(restoredMidFormationState?.sortieFormationAssignments) === JSON.stringify(midCampFormationAssignments) && JSON.stringify(restoredMidFormationState?.sortieItemAssignments) === JSON.stringify(midCampItemAssignments), `Expected the staged formation screen to preserve role and supply assignments: ${JSON.stringify(restoredMidFormationState)}` ); const restoredMidLoadoutState = await advanceSortiePrepStep(page, 'loadout'); assertSameMembers( restoredMidLoadoutState?.selectedSortieUnitIds, midCampSelectedSortieUnitIds, `Expected briefing, formation, and loadout steps to preserve the selected mid-campaign roster: ${JSON.stringify(restoredMidLoadoutState?.selectedSortieUnitIds)}` ); assert( JSON.stringify(restoredMidLoadoutState?.sortieFormationAssignments) === JSON.stringify(midCampFormationAssignments) && JSON.stringify(restoredMidLoadoutState?.sortieItemAssignments) === JSON.stringify(midCampItemAssignments), `Expected loadout navigation to preserve role and supply assignments: ${JSON.stringify(restoredMidLoadoutState)}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-loadout.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp sortie loadout'); await page.mouse.click(1116, 656); await advanceUntilBattle(page, midCampNextBattleId); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-next-battle.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp next battle'); const midNextBattleProbe = await readBattleDoctrineProbe(page); assert(midNextBattleProbe?.battleId === midCampNextBattleId, `Expected mid-campaign sortie to enter next battle: ${JSON.stringify(midNextBattleProbe)} / ${midCampNextBattleId}`); assertSameMembers( midNextBattleProbe?.selectedSortieUnitIds, midCampSelectedSortieUnitIds, `Expected mid-campaign next battle to receive selected sortie ids: ${JSON.stringify(midNextBattleProbe?.selectedSortieUnitIds)} / ${JSON.stringify(midCampSelectedSortieUnitIds)}` ); assertSameMembers( midNextBattleProbe?.deployedAllyIds, midCampSelectedSortieUnitIds, `Expected mid-campaign next battle deployed allies to match sortie ids: ${JSON.stringify(midNextBattleProbe?.deployedAllyIds)} / ${JSON.stringify(midCampSelectedSortieUnitIds)}` ); assertDeploymentMatchesPreview( midNextBattleProbe?.deployedAllyPositions, midCampDeploymentPreview, `Expected mid-campaign next battle ally positions to match deployment preview: ${JSON.stringify(midNextBattleProbe?.deployedAllyPositions)} / ${JSON.stringify(midCampDeploymentPreview)}` ); assertSortieDoctrine(midNextBattleProbe, { requireReserve: true }); assert( midNextBattleProbe.sortieDoctrine?.coveredRoleCount < 3, `Expected the seeded mid-campaign formation to allow a missing doctrine role: ${JSON.stringify(midNextBattleProbe.sortieDoctrine)}` ); assert( midNextBattleProbe.firstSortieRoleEffects?.active === false && midNextBattleProbe.firstSortieRoleEffects?.trinityActive === false, `Expected Peach Garden trinity to remain exclusive to the second battle: ${JSON.stringify(midNextBattleProbe.firstSortieRoleEffects)}` ); assert( midNextBattleProbe.units.some((unit) => unit.faction === 'ally' && !['liu-bei', 'guan-yu', 'zhang-fei'].includes(unit.id) && ['front', 'flank', 'support'].includes(unit.formationRole) && unit.sortieRoleEffect ), `Expected a non-Peach-Garden officer to receive a generic sortie role effect: ${JSON.stringify(midNextBattleProbe.units)}` ); const midResultFixture = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const state = window.__HEROS_DEBUG__?.battle(); const contributor = state?.units?.find((unit) => unit.faction === 'ally' && ['front', 'flank', 'support'].includes(unit.formationRole) ); const wounded = state?.units?.find((unit) => unit.faction === 'ally' && unit.id !== contributor?.id && unit.hp > 1 ) ?? contributor; const liveWounded = wounded?.id ? scene?.debugUnitById?.(wounded.id) : undefined; if (!scene || !contributor || !liveWounded || typeof scene.statsFor !== 'function') { return { ready: false, contributorId: contributor?.id ?? null, woundedUnitId: wounded?.id ?? null }; } const stats = scene.statsFor(contributor.id); Object.assign(stats, { damageDealt: 137, damageTaken: 22, defeats: 3, actions: 5, support: 11, sortieBonusDamage: 17, sortiePreventedDamage: 9, sortieBonusHealing: 4, sortieExtendedBondTriggers: 2, intentDefeats: 1, intentLineBreaks: 1, intentGuardSuccesses: 1 }); liveWounded.hp = Math.max(1, liveWounded.maxHp - 7); return { ready: true, contributorId: contributor.id, woundedUnitId: liveWounded.id, woundedHp: liveWounded.hp, woundedMaxHp: liveWounded.maxHp, expectedStats: { damageDealt: 137, damageTaken: 22, defeats: 3, actions: 5, support: 11, sortieBonusDamage: 17, sortiePreventedDamage: 9, sortieBonusHealing: 4, sortieExtendedBondTriggers: 2, intentDefeats: 1, intentLineBreaks: 1, intentGuardSuccesses: 1 } }; }); assert( midResultFixture.ready === true && midResultFixture.contributorId && midResultFixture.woundedUnitId && midResultFixture.woundedHp < midResultFixture.woundedMaxHp, `Expected a deterministic contribution and recovery fixture for the mid-campaign result: ${JSON.stringify(midResultFixture)}` ); await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await waitForBattleOutcome(page, 'victory'); const midResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const midResultSaveBeforeUpdate = await readCampaignSave(page); const midResultPerformance = midResultState?.sortieEvaluation?.snapshot; const midContributorPerformance = midResultPerformance?.unitStats?.find((stats) => stats.unitId === midResultFixture.contributorId); const midBattleRoleById = Object.fromEntries( (midNextBattleProbe?.deployedAllyPositions ?? []).map((unit) => [unit.id, unit.formationRole]) ); const expectedMidResultPreset = { selectedUnitIds: [...(midResultPerformance?.selectedUnitIds ?? [])], formationAssignments: { ...(midResultPerformance?.formationAssignments ?? {}) } }; assert( midResultState?.sortieEvaluation?.available === true && midResultState.sortieEvaluation.open === false && midResultState.sortieEvaluation.totalDamage === midResultFixture.expectedStats.damageDealt && midResultState.sortieEvaluation.totalDamageTaken === midResultFixture.expectedStats.damageTaken && midResultState.sortieEvaluation.totalDefeats === midResultFixture.expectedStats.defeats && midResultState.sortieEvaluation.totalSupport === midResultFixture.expectedStats.support && midResultState.sortieEvaluation.recoveryNeededCount > 0 && midResultState.sortieEvaluation.roleCoverage < 3, `Expected the mid-campaign evaluation to reflect the deterministic contribution, injury, and missing role: ${JSON.stringify(midResultState?.sortieEvaluation)}` ); assert( midContributorPerformance && Object.entries(midResultFixture.expectedStats).every(([key, value]) => midContributorPerformance[key] === value) && midContributorPerformance.hpBefore > 0 && midContributorPerformance.maxHpBefore >= midContributorPerformance.hpBefore && sameJsonValue([...(midResultPerformance?.selectedUnitIds ?? [])].sort(), [...midCampSelectedSortieUnitIds].sort()) && Object.keys(midResultPerformance?.formationAssignments ?? {}).length === midCampSelectedSortieUnitIds.length && midCampSelectedSortieUnitIds.every( (unitId) => midResultPerformance?.formationAssignments?.[unitId] === midBattleRoleById[unitId] ), `Expected the saved result snapshot to retain the exact roster, roles, starting HP, and contribution stats: ${JSON.stringify(midResultPerformance)}` ); assert( sameJsonValue(midResultSaveBeforeUpdate.current?.firstBattleReport?.sortiePerformance, midResultPerformance) && sameJsonValue(midResultSaveBeforeUpdate.slot1?.firstBattleReport?.sortiePerformance, midResultPerformance) && sameJsonValue(midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) && sameJsonValue(midResultSaveBeforeUpdate.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) && !sameJsonValue(midResultSaveBeforeUpdate.current?.sortieFormationPresets?.mobile, expectedMidResultPreset), `Expected the mid result snapshot to synchronize everywhere while remaining meaningfully different from the saved mobile preset: ${JSON.stringify({ snapshot: midResultPerformance, mobile: midResultSaveBeforeUpdate.current?.sortieFormationPresets?.mobile })}` ); const midEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle'); await page.mouse.click(midEvaluationTogglePoint.x, midEvaluationTogglePoint.y); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true, undefined, { timeout: 30000 }); const midEvaluationProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation; return { evaluation, texts: (scene?.resultFormationReviewObjects ?? []) .filter((object) => object?.type === 'Text') .map((object) => object.text) }; }); const midEvaluationSave = await readCampaignSave(page); const mobileResultCard = midEvaluationProbe.evaluation?.presetCards?.find((card) => card.id === 'mobile'); assert( midEvaluationProbe.texts.includes('이번 편성 평가') && midEvaluationProbe.texts.some((text) => text.includes('기동') && text.includes('갱신')) && mobileResultCard?.saved === true && mobileResultCard.matching === false && mobileResultCard.actionButtonBounds && sameJsonValue(midEvaluationSave, midResultSaveBeforeUpdate), `Expected opening the evaluation to expose a non-destructive mobile preset update action: ${JSON.stringify(midEvaluationProbe)}` ); const firstMobileUpdatePoint = await readBattleResultEvaluationControlPoint(page, 'preset', 'mobile'); await page.mouse.click(firstMobileUpdatePoint.x, firstMobileUpdatePoint.y); await page.waitForFunction(() => { const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation; return evaluation?.open === true && evaluation?.overwriteConfirmId === 'mobile' && evaluation?.feedback?.includes('한 번 더'); }, undefined, { timeout: 30000 }); const pendingMobileUpdateState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const pendingMobileUpdateSave = await readCampaignSave(page); assert( pendingMobileUpdateState?.resultVisible === true && pendingMobileUpdateState?.sortieEvaluation?.sourcePresetIds?.includes('mobile') === false && sameJsonValue(pendingMobileUpdateSave, midResultSaveBeforeUpdate), `Expected the first mobile update click to request confirmation without mutating either save: ${JSON.stringify({ evaluation: pendingMobileUpdateState?.sortieEvaluation, save: pendingMobileUpdateSave })}` ); const confirmMobileUpdatePoint = await readBattleResultEvaluationControlPoint(page, 'preset', 'mobile'); await page.mouse.click(confirmMobileUpdatePoint.x, confirmMobileUpdatePoint.y); await page.waitForFunction(() => { const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation; return evaluation?.open === true && evaluation?.overwriteConfirmId === null && evaluation?.sourcePresetIds?.includes('mobile') && evaluation?.feedback?.includes('갱신 완료'); }, undefined, { timeout: 30000 }); const updatedMobileResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const updatedMobileResultSave = await readCampaignSave(page); const updatedCurrentMobilePreset = updatedMobileResultSave.current?.sortieFormationPresets?.mobile; const updatedSlotMobilePreset = updatedMobileResultSave.slot1?.sortieFormationPresets?.mobile; assert( sameJsonValue(updatedCurrentMobilePreset, expectedMidResultPreset) && sameJsonValue(updatedSlotMobilePreset, expectedMidResultPreset) && JSON.stringify(Object.keys(updatedCurrentMobilePreset ?? {}).sort()) === JSON.stringify(['formationAssignments', 'selectedUnitIds']) && updatedCurrentMobilePreset?.sortieItemAssignments === undefined && updatedCurrentMobilePreset?.itemAssignments === undefined && updatedCurrentMobilePreset?.unitStats === undefined, `Expected the confirmed result action to store only the evaluated roster and roles in current and slot-1 mobile presets: ${JSON.stringify(updatedMobileResultSave)}` ); assert( JSON.stringify(updatedMobileResultSave.current?.selectedSortieUnitIds) === JSON.stringify(midResultSaveBeforeUpdate.current?.selectedSortieUnitIds) && sameJsonValue(updatedMobileResultSave.current?.sortieFormationAssignments, midResultSaveBeforeUpdate.current?.sortieFormationAssignments) && sameJsonValue(updatedMobileResultSave.current?.sortieItemAssignments, midResultSaveBeforeUpdate.current?.sortieItemAssignments) && sameJsonValue(updatedMobileResultSave.slot1?.selectedSortieUnitIds, midResultSaveBeforeUpdate.slot1?.selectedSortieUnitIds) && sameJsonValue(updatedMobileResultSave.slot1?.sortieFormationAssignments, midResultSaveBeforeUpdate.slot1?.sortieFormationAssignments) && sameJsonValue(updatedMobileResultSave.slot1?.sortieItemAssignments, midResultSaveBeforeUpdate.slot1?.sortieItemAssignments) && sameJsonValue(updatedMobileResultSave.current?.firstBattleReport?.sortiePerformance, midResultPerformance) && sameJsonValue(updatedMobileResultSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) && sameJsonValue(updatedMobileResultSave.current?.sortieFormationPresets?.elite, midResultSaveBeforeUpdate.current?.sortieFormationPresets?.elite) && sameJsonValue(updatedMobileResultSave.current?.sortieFormationPresets?.siege, midResultSaveBeforeUpdate.current?.sortieFormationPresets?.siege), `Expected result-to-preset update to leave the active sortie, supplies, report snapshot, and other presets unchanged: ${JSON.stringify({ before: midResultSaveBeforeUpdate, after: updatedMobileResultSave })}` ); assert( updatedMobileResultState?.sortieEvaluation?.presetCards?.find((card) => card.id === 'mobile')?.matching === true && updatedMobileResultState?.sortieEvaluation?.feedback?.includes('장비·보급은 저장하지 않았습니다.'), `Expected visible completion feedback and a matching mobile result card after update: ${JSON.stringify(updatedMobileResultState?.sortieEvaluation)}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-formation-evaluation-updated.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp formation evaluation update'); await seedCampaignSave(page, { battleId: 'sixty-sixth-battle-wuzhang-final', battleTitle: '오장원 최종전', step: 'sixty-sixth-camp', gold: 9800, turnNumber: 18, defeatedEnemies: 20, selectedSortieUnitIds: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'ma-dai', 'huang-quan', 'li-yan', 'wei-yan'] }); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); await page.mouse.click(962, 310); await waitForCamp(page); const finalCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert(finalCampState?.campaign?.step === 'sixty-sixth-camp', `Expected final camp save to continue: ${JSON.stringify(finalCampState)}`); assert(finalCampState.sortieHasBattle === false, `Expected final camp to expose no playable sortie target: ${JSON.stringify(finalCampState)}`); assert(finalCampState.stagedSortiePrep === false, `Expected the non-battle final camp to avoid the three-stage sortie flow: ${JSON.stringify(finalCampState)}`); assert(finalCampState.nextSortieBattleId === null, `Expected final camp to route to ending instead of a battle: ${JSON.stringify(finalCampState)}`); assert( 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 page.mouse.click(1160, 38); const finalTransitionScenes = await waitForStoryOrEnding(page); assert(!finalTransitionScenes.includes('BattleScene'), `Expected final camp transition to avoid BattleScene: ${JSON.stringify(finalTransitionScenes)}`); if (finalTransitionScenes.includes('StoryScene')) { await waitForStoryReady(page); await advanceStoryUntilEnding(page); } await waitForEnding(page); await page.screenshot({ path: `${screenshotDir}/rc-ending.png`, fullPage: true }); await assertCanvasPainted(page, 'ending'); const endingState = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('EndingScene')?.getDebugState?.()); assert(endingState?.campaignStep === 'ending-complete', `Expected ending-complete state: ${JSON.stringify(endingState)}`); assert(endingState.latestBattleId === 'sixty-sixth-battle-wuzhang-final', `Expected Wuzhang final as latest battle: ${JSON.stringify(endingState)}`); await page.keyboard.press('Enter'); await waitForTitle(page); await page.mouse.click(962, 310); await waitForEnding(page); await page.screenshot({ path: `${screenshotDir}/rc-ending-continue.png`, fullPage: true }); await assertCanvasPainted(page, 'ending continue'); const endingContinueState = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], ending: window.__HEROS_DEBUG__?.scene('EndingScene')?.getDebugState?.() })); assert( endingContinueState.activeScenes.includes('EndingScene') && endingContinueState.ending?.campaignStep === 'ending-complete', `Expected title continue to reopen ending: ${JSON.stringify(endingContinueState)}` ); const relevantLogs = consoleMessages.filter( (message) => message.type === 'error' || (isWarning(message.type) && relevantLogPattern.test(message.text)) ); const blockingRequestFailures = requestFailures.filter((request) => request.failure !== 'net::ERR_ABORTED'); assert(relevantLogs.length === 0, `Unexpected browser console issues: ${JSON.stringify(relevantLogs)}`); assert(pageErrors.length === 0, `Unexpected browser runtime errors: ${JSON.stringify(pageErrors)}`); assert(blockingRequestFailures.length === 0, `Unexpected browser request failures: ${JSON.stringify(blockingRequestFailures)}`); console.log(`Verified release-candidate user flow at ${targetUrl}`); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { serverProcess.kill(); } } function withDebugParam(url) { const parsed = new URL(url); parsed.searchParams.set('debug', '1'); parsed.searchParams.set('renderer', 'canvas'); return parsed.toString(); } function isWarning(type) { return type === 'warning' || type === 'warn'; } async function waitForTitle(page) { await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 }); await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 }); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('TitleScene'); }, undefined, { timeout: 90000 }); } async function assertReleaseShellReady(page) { const state = await page.evaluate(() => ({ title: document.title, canvasCount: document.querySelectorAll('canvas').length, debugApiPresent: Boolean(window.__HEROS_DEBUG__), gameApiPresent: Boolean(window.__HEROS_GAME__) })); assert(state.title === expectedTitle, `Expected release page title "${expectedTitle}": ${JSON.stringify(state)}`); assert(state.canvasCount === 1, `Expected exactly one release game canvas: ${JSON.stringify(state)}`); assert(state.debugApiPresent === true, `Expected release QA debug API to be enabled: ${JSON.stringify(state)}`); assert(state.gameApiPresent === true, `Expected release QA game handle to be enabled: ${JSON.stringify(state)}`); } async function waitForStoryReady(page) { await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.(); return activeScenes.includes('StoryScene') && story?.ready === true; }, undefined, { timeout: 90000 }); } async function waitForStoryOrEnding(page) { await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene') || activeScenes.includes('EndingScene'); }, undefined, { timeout: 30000 }); return page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); } async function advanceUntilBattle(page, battleId) { for (let i = 0; i < 48; i += 1) { const ready = await page.evaluate((expectedBattleId) => { const state = window.__HEROS_DEBUG__?.battle(); return ( state?.scene === 'BattleScene' && state?.battleId === expectedBattleId && (state?.phase === 'deployment' || state?.phase === 'idle') && state?.mapBackgroundReady === true ); }, battleId); if (ready) { await startDeploymentIfNeeded(page, battleId); return; } await page.keyboard.press('Space'); await page.waitForTimeout(220); } await waitForBattleReady(page, battleId); } async function waitForBattleReady(page, battleId) { await page.waitForFunction((expectedBattleId) => { const state = window.__HEROS_DEBUG__?.battle(); return ( state?.scene === 'BattleScene' && state?.battleId === expectedBattleId && (state?.phase === 'deployment' || state?.phase === 'idle') && state?.mapBackgroundReady === true ); }, battleId, { timeout: 90000 }); await startDeploymentIfNeeded(page, battleId); } async function startDeploymentIfNeeded(page, battleId) { const state = await page.evaluate((expectedBattleId) => { const battle = window.__HEROS_DEBUG__?.battle(); return battle?.battleId === expectedBattleId ? battle : undefined; }, battleId); if (state?.phase !== 'deployment') { return; } assert(state.deployedAllyIds?.length >= 3, `Expected deployment to show the selected ally formation: ${JSON.stringify(state)}`); await page.mouse.click(1085, 637); await page.waitForFunction((expectedBattleId) => { const battle = window.__HEROS_DEBUG__?.battle(); return battle?.battleId === expectedBattleId && battle?.phase === 'idle' && battle?.triggeredBattleEvents?.includes('opening'); }, battleId, { timeout: 30000 }); } async function waitForBattleOutcome(page, outcome) { await page.waitForFunction((expectedOutcome) => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === expectedOutcome && state?.phase === 'resolved' && state?.resultVisible === true; }, outcome, { timeout: 90000 }); } async function waitForCamp(page) { await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const scene = window.__HEROS_DEBUG__?.scene('CampScene'); return ( activeScenes.includes('CampScene') && window.__HEROS_DEBUG__?.camp()?.scene === 'CampScene' && (scene?.contentObjects?.length ?? 0) > 0 ); }, undefined, { timeout: 90000 }); } async function waitForSortiePrep(page) { await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const camp = window.__HEROS_DEBUG__?.camp?.(); return activeScenes.includes('CampScene') && camp?.sortieVisible === true; }, undefined, { timeout: 30000 }); } async function advanceSortiePrepStep(page, expectedStep) { await page.mouse.click(1116, 656); await page.waitForFunction((step) => { const camp = window.__HEROS_DEBUG__?.camp?.(); return camp?.sortieVisible === true && camp?.sortiePrepStep === step; }, expectedStep, { timeout: 30000 }); await page.waitForTimeout(1000); const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()); const expectedNextStep = expectedStep === 'formation' ? 'loadout' : null; assert( state?.stagedSortiePrep === true && state?.sortiePrepSteps?.filter((step) => step.active).length === 1 && state?.sortiePrepSteps?.some((step) => step.id === expectedStep && step.active) && state?.sortiePrimaryAction?.kind === (expectedNextStep ? 'next' : 'launch') && state?.sortiePrimaryAction?.nextStep === expectedNextStep, `Expected staged sortie navigation to reach ${expectedStep}: ${JSON.stringify(state)}` ); return state; } async function waitForCampAfterBattleResult(page) { await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene') || activeScenes.includes('StoryScene'); }, undefined, { timeout: 90000 }); for (let i = 0; i < 48; i += 1) { const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); if (activeScenes.includes('CampScene')) { await waitForCamp(page); return; } await page.keyboard.press('Space'); await page.waitForTimeout(240); } await waitForCamp(page); } async function advanceStoryUntilEnding(page) { for (let i = 0; i < 48; i += 1) { const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); if (activeScenes.includes('EndingScene')) { return; } await page.keyboard.press('Space'); await page.waitForTimeout(240); } } async function waitForEnding(page) { await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const ending = window.__HEROS_DEBUG__?.scene('EndingScene')?.getDebugState?.(); return activeScenes.includes('EndingScene') && ending?.ready === true && ending?.campaignStep === 'ending-complete'; }, undefined, { timeout: 90000 }); } async function assertCanvasPainted(page, context) { const probe = await page.evaluate(() => { const canvas = document.querySelector('canvas'); if (!canvas) { return { readable: false, missing: true, sampleCount: 0, nonTransparentRatio: 0, distinctColorBuckets: 0 }; } const canvasContext = canvas.getContext('2d', { willReadFrequently: true }); if (!canvasContext) { return { readable: false, missing: false, sampleCount: 0, nonTransparentRatio: 0, distinctColorBuckets: 0 }; } const stepX = Math.max(1, Math.floor(canvas.width / 64)); const stepY = Math.max(1, Math.floor(canvas.height / 64)); const colorBuckets = new Set(); let sampleCount = 0; let nonTransparentCount = 0; for (let y = Math.floor(stepY / 2); y < canvas.height; y += stepY) { for (let x = Math.floor(stepX / 2); x < canvas.width; x += stepX) { const [r, g, b, a] = canvasContext.getImageData(x, y, 1, 1).data; sampleCount += 1; if (a > 8) { nonTransparentCount += 1; } colorBuckets.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 5}`); } } return { readable: true, missing: false, sampleCount, nonTransparentRatio: sampleCount > 0 ? nonTransparentCount / sampleCount : 0, distinctColorBuckets: colorBuckets.size }; }); assert(probe.readable === true, `Expected readable canvas pixels during ${context}: ${JSON.stringify(probe)}`); assert(probe.sampleCount >= 500, `Expected enough canvas pixel samples during ${context}: ${JSON.stringify(probe)}`); assert(probe.nonTransparentRatio > 0.5, `Expected non-empty canvas pixels during ${context}: ${JSON.stringify(probe)}`); assert(probe.distinctColorBuckets >= 12, `Expected visually varied canvas pixels during ${context}: ${JSON.stringify(probe)}`); } async function readCampaignSave(page) { return page.evaluate(() => { const read = (key) => { const raw = window.localStorage.getItem(key); return raw ? JSON.parse(raw) : undefined; }; return { current: read('heros-web:campaign-state'), slot1: read('heros-web:campaign-state:slot-1') }; }); } async function readBattleResultEvaluationControlPoint(page, control, presetId) { const probe = await page.evaluate(({ control, presetId }) => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation; const canvas = document.querySelector('canvas'); const presetCard = evaluation?.presetCards?.find((card) => card.id === presetId); const bounds = control === 'toggle' ? evaluation?.toggleButtonBounds : control === 'close' ? evaluation?.closeButtonBounds : presetCard?.actionButtonBounds; if (!scene || !canvas || !bounds) { return { ready: false, control, presetId: presetId ?? null, evaluationOpen: evaluation?.open ?? false, bounds: bounds ?? null }; } const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, control, presetId: presetId ?? null, evaluationOpen: evaluation?.open ?? false, bounds, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, { control, presetId }); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), `Expected a visible Phaser battle-result evaluation ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}` ); return probe; } async function readSortieComparisonActionPoint(page) { const probe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const actionButton = scene?.sortieComparisonPanelView?.actionButton; const actionLabel = scene?.sortieComparisonPanelView?.actionLabel; const canvas = document.querySelector('canvas'); if (!scene || !actionButton?.visible || !actionLabel?.visible || !canvas) { return { ready: false, buttonVisible: actionButton?.visible ?? false, labelVisible: actionLabel?.visible ?? false, label: actionLabel?.text ?? null }; } const bounds = actionButton.getBounds(); const canvasBounds = canvas.getBoundingClientRect(); const logicalWidth = scene.scale.width; const logicalHeight = scene.scale.height; const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, label: actionLabel.text, bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, canvasBounds: { x: canvasBounds.x, y: canvasBounds.y, width: canvasBounds.width, height: canvasBounds.height }, logicalSize: { width: logicalWidth, height: logicalHeight }, x: canvasBounds.left + centerX * canvasBounds.width / logicalWidth, y: canvasBounds.top + centerY * canvasBounds.height / logicalHeight }; }); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), `Expected a visible Phaser comparison action with a canvas-scaled click point: ${JSON.stringify(probe)}` ); return probe; } async function readSortiePresetControlPoint(page, control, presetId) { const probe = await page.evaluate(({ control, presetId }) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const state = window.__HEROS_DEBUG__?.camp(); const canvas = document.querySelector('canvas'); const presetCard = state?.sortiePresetBrowser?.cards?.find((card) => card.id === presetId); const bounds = control === 'toggle' ? state?.sortiePresetBrowser?.toggleButtonBounds : control === 'close' ? state?.sortiePresetBrowser?.closeButtonBounds : control === 'card' ? presetCard?.backgroundBounds : control === 'compare' ? presetCard?.compareButtonBounds : presetCard?.saveButtonBounds; if (!scene || !canvas || !bounds) { return { ready: false, control, presetId: presetId ?? null, bounds: bounds ?? null }; } const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, control, presetId: presetId ?? null, bounds, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, { control, presetId }); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), `Expected a visible Phaser preset ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}` ); return probe; } async function readBattleDoctrineProbe(page) { return page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const roleSeals = (state?.units ?? []) .filter((unit) => unit.faction === 'ally') .map((unit) => { const seal = scene?.unitViews?.get(unit.id)?.roleSeal; return { unitId: unit.id, text: seal?.text ?? null, visible: seal?.visible ?? false }; }); const openingBannerTexts = (scene?.battleEventObjects ?? []) .filter((object) => object?.type === 'Text') .map((object) => object.text); return { ...state, roleSeals, openingBannerTexts }; }); } async function seedCampaignSave(page, options) { await page.evaluate((seed) => { const storageKey = 'heros-web:campaign-state'; const current = JSON.parse(window.localStorage.getItem(storageKey) ?? 'null'); if (!current?.firstBattleReport) { throw new Error('Expected an existing campaign report before seeding RC save.'); } const now = new Date().toISOString(); const objectives = [ { id: 'leader', label: '지휘관 격파', achieved: true, status: 'done', detail: '전장 목표 완료', rewardGold: 420 }, { id: 'survive', label: '본대 보존', achieved: true, status: 'done', detail: '핵심 장수 생존', rewardGold: 260 }, { id: 'supply', label: '보급선 유지', achieved: true, status: 'done', detail: '수레와 군량 확보', rewardGold: 220 }, { id: 'quick', label: '빠른 정리', achieved: false, status: 'failed', detail: `${seed.turnNumber}/12턴`, rewardGold: 180 } ]; const units = current.roster?.length ? current.roster : current.firstBattleReport.units; const bonds = current.bonds?.length ? current.bonds : current.firstBattleReport.bonds; const report = { ...current.firstBattleReport, battleId: seed.battleId, battleTitle: seed.battleTitle, outcome: 'victory', turnNumber: seed.turnNumber, rewardGold: 1080, defeatedEnemies: seed.defeatedEnemies, totalEnemies: seed.defeatedEnemies, objectives, units, bonds, mvp: current.firstBattleReport.mvp ?? { unitId: 'zhuge-liang', name: '제갈량', damageDealt: 320, defeats: 2 }, itemRewards: ['상처약 2', '탁주 1'], completedCampDialogues: current.completedCampDialogues ?? [], completedCampVisits: current.completedCampVisits ?? [], createdAt: now }; delete report.sortiePerformance; const settlement = { battleId: report.battleId, battleTitle: report.battleTitle, outcome: 'victory', rewardGold: report.rewardGold, itemRewards: report.itemRewards, objectives, units: units.map((unit) => ({ unitId: unit.id, name: unit.name, level: unit.level ?? 1, exp: unit.exp ?? 0, hp: unit.hp ?? unit.maxHp ?? 1, maxHp: unit.maxHp ?? 1, equipment: unit.equipment })), bonds: bonds.map((bond) => ({ id: bond.id, title: bond.title, level: bond.level ?? 1, exp: bond.exp ?? 0, battleExp: bond.battleExp ?? 0 })), reserveTraining: [], completedAt: now }; const next = { ...current, updatedAt: now, step: seed.step, activeSaveSlot: 1, gold: seed.gold, inventory: { ...(current.inventory ?? {}), 콩: 12, 상처약: 6, 탁주: 4 }, selectedSortieUnitIds: seed.selectedSortieUnitIds, latestBattleId: seed.battleId, firstBattleReport: report, battleHistory: { ...(current.battleHistory ?? {}), [seed.battleId]: settlement } }; window.localStorage.setItem(storageKey, JSON.stringify(next)); window.localStorage.setItem(`${storageKey}:slot-1`, JSON.stringify(next)); }, options); } async function ensurePreviewServer(url) { if (await canReach(url)) { return undefined; } const parsed = new URL(url); const isLocal = ['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname); if (!isLocal) { throw new Error(`No server responded at ${url}`); } const stderr = []; const child = spawn(process.execPath, ['node_modules/vite/bin/vite.js', 'preview', '--host', '127.0.0.1', '--port', parsed.port || '4173'], { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); child.stderr.on('data', (chunk) => stderr.push(chunk.toString())); child.stdout.on('data', () => {}); for (let i = 0; i < 120; i += 1) { if (await canReach(url)) { return child; } await delay(250); } child.kill(); throw new Error(`Vite preview did not start at ${url}\n${stderr.join('')}`); } async function canReach(url) { try { const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); return response.ok; } catch { return false; } } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function assert(condition, message) { if (!condition) { throw new Error(message); } } function assertUnique(values, message) { assert(Array.isArray(values) && values.length > 0, message); assert(values.every(Boolean), message); assert(new Set(values).size === values.length, message); } function assertSameMembers(values, expected, message) { assert(Array.isArray(values) && Array.isArray(expected), message); assert(values.length === expected.length, message); const valueSet = new Set(values); assert(expected.every((value) => valueSet.has(value)), message); } function sameJsonValue(value, expected) { return stableJson(value) === stableJson(expected); } function stableJson(value) { return JSON.stringify(normalize(value)); function normalize(entry) { if (Array.isArray(entry)) { return entry.map(normalize); } if (entry && typeof entry === 'object') { return Object.fromEntries( Object.keys(entry) .sort() .map((key) => [key, normalize(entry[key])]) ); } return entry; } } function assertDeploymentMatchesPreview(positions, preview, message) { assert(Array.isArray(positions) && Array.isArray(preview), message); assert(positions.length === preview.length, message); const positionsById = new Map(positions.map((position) => [position.id, position])); assert( preview.every((slot) => { const position = positionsById.get(slot.unitId); return position?.x === slot.x && position?.y === slot.y; }), message ); } function assertSortieDoctrine(probe, options = {}) { const doctrine = probe?.sortieDoctrine; const expectedSealByRole = { front: '전', flank: '돌', support: '후' }; const expectedRoles = Object.keys(expectedSealByRole); const roles = doctrine?.roles ?? []; const roleById = new Map(roles.map((role) => [role.role, role])); const allyUnits = (probe?.units ?? []).filter((unit) => unit.faction === 'ally'); const sealsByUnitId = new Map((probe?.roleSeals ?? []).map((seal) => [seal.unitId, seal])); const effectUnits = allyUnits.filter((unit) => expectedRoles.includes(unit.formationRole)); const reserveUnits = allyUnits.filter((unit) => unit.formationRole === 'reserve'); const coveredRoleCount = roles.filter((role) => role.unitIds.length > 0).length; assert(doctrine?.active === true, `Expected sortie doctrine to be active: ${JSON.stringify(probe)}`); assert(doctrine?.openingBannerShown === true, `Expected sortie doctrine opening banner to be shown: ${JSON.stringify(doctrine)}`); assert( roles.length === expectedRoles.length && expectedRoles.every((role) => roleById.has(role)), `Expected doctrine debug state to expose front, flank, and support roles: ${JSON.stringify(doctrine)}` ); assert( doctrine.coveredRoleCount === coveredRoleCount, `Expected doctrine role coverage to match non-empty role groups: ${JSON.stringify(doctrine)}` ); if (options.requireFullCoverage) { assert(coveredRoleCount === 3, `Expected all three doctrine roles to be covered: ${JSON.stringify(doctrine)}`); } assert(effectUnits.length > 0, `Expected at least one deployed officer with a doctrine role effect: ${JSON.stringify(allyUnits)}`); effectUnits.forEach((unit) => { const role = roleById.get(unit.formationRole); const seal = sealsByUnitId.get(unit.id); assert( role?.unitIds.includes(unit.id) && unit.sortieRoleEffect?.label === role.label && unit.sortieRoleEffect?.summary === role.summary, `Expected ${unit.id} to expose the assigned ${unit.formationRole} doctrine effect: ${JSON.stringify({ unit, role })}` ); assert( seal?.text === expectedSealByRole[unit.formationRole] && unit.roleSealVisible === seal.visible, `Expected ${unit.id} to show the ${expectedSealByRole[unit.formationRole]} role seal: ${JSON.stringify({ unit, seal })}` ); if (options.requireVisibleSeals) { assert( unit.roleSealVisible === true && seal.visible === true, `Expected ${unit.id} role seal to be visible in the opening formation: ${JSON.stringify({ unit, seal })}` ); } }); if (options.requireReserve) { assert(reserveUnits.length > 0, `Expected the doctrine probe to include a reserve officer: ${JSON.stringify(allyUnits)}`); } reserveUnits.forEach((unit) => { const seal = sealsByUnitId.get(unit.id); assert( unit.sortieRoleEffect === null && unit.roleSealVisible === false && seal?.visible === false && seal?.text === null && roles.every((role) => !role.unitIds.includes(unit.id)), `Expected reserve officer ${unit.id} to have no doctrine effect or role seal: ${JSON.stringify({ unit, seal, doctrine })}` ); }); } function runCommand(command, args, env = {}) { const result = spawnSync(command, args, { cwd: process.cwd(), env: { ...process.env, ...env }, stdio: 'inherit' }); if (result.status !== 0) { throw new Error(`Command failed: ${command} ${args.join(' ')}`); } }