import { spawn, spawnSync } from 'node:child_process'; import { mkdirSync, writeFileSync } 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 baselineViewport = { width: 1920, height: 1080 }; const phaserWebglRendererType = 2; const legacyUiScale = 1.5; 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; async function clickLegacyUi(page, x, y, options) { await page.mouse.click(x * legacyUiScale, y * legacyUiScale, options); } async function moveLegacyUi(page, x, y, options) { await page.mouse.move(x * legacyUiScale, y * legacyUiScale, options); } async function clickBattleDeploymentStart(page) { const point = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const startButton = window.__HEROS_DEBUG__?.battle()?.battleHud?.deploymentPanel?.startButtonBounds; const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); if (!scene || !startButton || !canvas || !bounds) { return null; } const logicalX = startButton.x + startButton.width / 2; const logicalY = startButton.y + startButton.height / 2; return { x: bounds.left + logicalX * bounds.width / scene.scale.width, y: bounds.top + logicalY * bounds.height / scene.scale.height }; }); assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a runtime deployment start point: ${JSON.stringify(point)}`); await page.mouse.click(point.x, point.y); } 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: baselineViewport }); 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 assertHdPlusCanvasFit(page); await assertHdPlusInputMapping(browser, targetUrl); await assertLargeRosterHud(browser, targetUrl); await assertWebglBattleQuickTabSmoke(browser, targetUrl, 'first-battle-zhuo-commandery'); 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 clickLegacyUi(page, 962, 240); await waitForStoryReady(page); await setDebugQueryParam(page, 'debugOrderCommandReady', '1'); 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); const miniMapTexts = textValues((scene?.miniMapObjects ?? []).filter((object) => object?.active && object?.visible)); const miniMapFrame = (scene?.miniMapObjects ?? []).find((object) => object?.name === 'mini-map-frame'); return { battleId: state?.battleId, objectiveText: scene?.objectiveTrackerText?.text, objectiveSubText: scene?.objectiveTrackerSubText?.text, sideTexts, miniMapTexts, actualMiniMapFrameBounds: miniMapFrame ? boundsValue(miniMapFrame.getBounds()) : null, hud: state?.battleHud ?? null, camera: state?.camera ?? null, battleLog: [...(state?.battleLog ?? [])], sceneBounds: scene ? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height } : null, panelBounds: scene ? { x: scene.layout.panelX, y: scene.layout.panelY, width: scene.layout.panelWidth, height: scene.layout.panelHeight } : null }; function textValues(objects = []) { return objects.filter((object) => object?.type === 'Text').map((object) => object.text); } function boundsValue(bounds) { return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; } }); 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?.startsWith('패배 조건:') && !firstBattleProbe.objectiveSubText.includes('진군:') && !firstBattleProbe.objectiveSubText.includes('보조:'), `Expected the compact objective subtext to retain only the defeat condition without duplicate route or bonus guidance: ${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)}` ); const firstBattleMiniMap = firstBattleProbe.hud?.miniMap; const firstBattleRecentActions = firstBattleProbe.hud?.recentActions; assert( firstBattleMiniMap?.visible === true && firstBattleMiniMap.title === '전장 지도' && sameJsonValue(firstBattleMiniMap.legend, ['아', '적', '목']) && firstBattleProbe.sceneBounds?.width === 1920 && firstBattleProbe.sceneBounds?.height === 1080 && firstBattleMiniMap.cellSize >= 1 && firstBattleMiniMap.objectiveHighlighted === true && firstBattleMiniMap.selectedUnitId === null && firstBattleMiniMap.selectedHighlighted === false && firstBattleProbe.miniMapTexts.includes('전장 지도') && ['아', '적', '목'].every((label) => firstBattleProbe.miniMapTexts.includes(label)) && sameJsonValue(firstBattleProbe.actualMiniMapFrameBounds, firstBattleMiniMap.frameBounds) && isFiniteBounds(firstBattleProbe.sceneBounds) && isFiniteBounds(firstBattleProbe.panelBounds) && isFiniteBounds(firstBattleMiniMap.frameBounds) && isFiniteBounds(firstBattleMiniMap.headerBounds) && isFiniteBounds(firstBattleMiniMap.mapBounds) && isFiniteBounds(firstBattleMiniMap.viewportBounds) && boundsInside(firstBattleProbe.panelBounds, firstBattleProbe.sceneBounds) && boundsInside(firstBattleMiniMap.frameBounds, firstBattleProbe.panelBounds) && boundsInside(firstBattleMiniMap.headerBounds, firstBattleMiniMap.frameBounds) && boundsInside(firstBattleMiniMap.mapBounds, firstBattleMiniMap.frameBounds) && boundsInside(firstBattleMiniMap.viewportBounds, firstBattleMiniMap.mapBounds, 2) && firstBattleMiniMap.mapBounds.width === firstBattleProbe.camera?.mapWidth * firstBattleMiniMap.cellSize && firstBattleMiniMap.mapBounds.height === firstBattleProbe.camera?.mapHeight * firstBattleMiniMap.cellSize && firstBattleMiniMap.headerBounds.y + firstBattleMiniMap.headerBounds.height <= firstBattleMiniMap.mapBounds.y, `Expected a framed tactical minimap with title, legend, objective emphasis, and separate map hit bounds: ${JSON.stringify(firstBattleProbe)}` ); assert( isFiniteBounds(firstBattleRecentActions) && boundsInside(firstBattleRecentActions, firstBattleProbe.panelBounds) && boundsInside(firstBattleRecentActions, firstBattleProbe.sceneBounds) && firstBattleRecentActions?.compact === false && firstBattleRecentActions.height === 126 && firstBattleRecentActions.rowCount === Math.min(3, firstBattleProbe.battleLog.length) && firstBattleRecentActions.textBounds.length === firstBattleRecentActions.rowCount && firstBattleRecentActions.textBounds.every((bounds) => boundsInside(bounds, firstBattleRecentActions)) && firstBattleProbe.sideTexts.includes('최근 행동') && firstBattleRecentActions.bottom === firstBattleRecentActions.y + firstBattleRecentActions.height && firstBattleRecentActions.gapToMiniMap >= 6 && firstBattleRecentActions.bottom <= firstBattleMiniMap.frameBounds.y, `Expected the FHD recent-action panel to show three noncompact rows without touching the minimap frame: ${JSON.stringify(firstBattleProbe.hud)}` ); await setDebugQueryParam(page, 'debugForceBondChain', '1'); const firstBattleBondChain = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const state = window.__HEROS_DEBUG__?.battle(); const mechanics = state?.combatMechanicsProbe; const attacker = mechanics?.attackerId ? scene?.debugUnitById(mechanics.attackerId) : undefined; const partner = mechanics?.partnerId ? scene?.debugUnitById(mechanics.partnerId) : undefined; const enemy = mechanics?.enemyId ? scene?.debugUnitById(mechanics.enemyId) : undefined; if (!scene || !attacker || !partner || !enemy) { return { chain: mechanics?.chain ?? null, forcedRoll: null, preview: null }; } const originalIntents = scene.attackIntents.map((intent) => ({ ...intent })); const originalActedUnitIds = new Set(scene.actedUnitIds); scene.attackIntents = [{ attackerId: partner.id, targetId: enemy.id }]; scene.actedUnitIds = new Set([...originalActedUnitIds, partner.id]); const preview = scene.combatPreview(attacker, enemy, 'attack'); scene.attackIntents = originalIntents; scene.actedUnitIds = originalActedUnitIds; return { chain: mechanics.chain ?? null, forcedRoll: scene.debugBondChainRollOverride(), preview: { rate: preview.bondChainRate, damage: preview.bondChainDamage, bondId: preview.bondChainBondId ?? null, partnerId: preview.bondChainPartnerId ?? null, partnerName: preview.bondChainPartnerName ?? null, label: preview.bondChainLabel ?? null } }; }); await setDebugQueryParam(page, 'debugForceBondChain', '0'); const firstBattleBondChainForcedFailure = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); return scene?.debugBondChainRollOverride() ?? null; }); await setDebugQueryParam(page, 'debugForceBondChain', null); assert( firstBattleBondChain.preview?.rate === 18 && firstBattleBondChain.preview.damage > 0 && firstBattleBondChain.preview.bondId && firstBattleBondChain.preview.partnerId === firstBattleBondChain.chain?.expectedSupporterId && firstBattleBondChain.preview.partnerName && firstBattleBondChain.preview.label, `Expected a complete level-72 resonance-pursuit preview contract: ${JSON.stringify(firstBattleBondChain)}` ); assert( firstBattleBondChain.forcedRoll === true && firstBattleBondChainForcedFailure === false && firstBattleBondChain.chain?.rate === 18 && firstBattleBondChain.chain.supportDamage === firstBattleBondChain.preview.damage && firstBattleBondChain.chain.ineligibleWithoutPriorIntent === true && firstBattleBondChain.chain.eligibleWithMatchingPriorIntent === true && firstBattleBondChain.chain.strategyIgnored === true && firstBattleBondChain.chain.counterIgnored === true && firstBattleBondChain.chain.missedBaseBlocked === true && firstBattleBondChain.chain.lethalBaseBlocked === true && firstBattleBondChain.chain.survivingBaseAllowed === true && firstBattleBondChain.chain.forcedSuccessTriggers === true && firstBattleBondChain.chain.forcedFailureBlocked === true, `Expected pursuit eligibility, hit/survival gates, and deterministic roll overrides: ${JSON.stringify(firstBattleBondChain)}` ); assert( firstBattleBondChain.chain?.statCredit?.partnerDamage === firstBattleBondChain.chain.supportDamage && firstBattleBondChain.chain.statCredit.partnerDefeats === 1 && firstBattleBondChain.chain.statCredit.partnerActions === 0 && firstBattleBondChain.chain.statCredit.defenderDamageTaken === firstBattleBondChain.chain.supportDamage && firstBattleBondChain.chain.followUpKillCancelsCounter === true && firstBattleBondChain.chain.intentClearedAtTurnReset === true, `Expected pursuit damage and defeat credit to belong to the supporter, cancel counters on defeat, and reset next turn: ${JSON.stringify(firstBattleBondChain.chain)}` ); const firstBattleBondChainReadyUi = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const stateBefore = window.__HEROS_DEBUG__?.battle(); const mechanics = stateBefore?.combatMechanicsProbe; const attacker = mechanics?.attackerId ? scene?.debugUnitById(mechanics.attackerId) : undefined; const partner = mechanics?.partnerId ? scene?.debugUnitById(mechanics.partnerId) : undefined; const target = mechanics?.enemyId ? scene?.debugUnitById(mechanics.enemyId) : undefined; const mismatchTarget = stateBefore?.units?.find( (unit) => unit.faction === 'enemy' && unit.hp > 0 && unit.id !== mechanics?.enemyId ); if ( !scene || !attacker || !partner || !target || !mismatchTarget || stateBefore?.phase !== 'idle' || stateBefore.selectedUnitId !== null || stateBefore.commandMenuVisible !== false ) { return { ready: false, reason: 'first battle was not in a clean idle state for the pursuit-ready UI fixture', stateBefore, mechanics }; } const touchedUnits = [attacker, partner, target]; const original = { phase: scene.phase, selectedUnit: scene.selectedUnit, pendingMove: scene.pendingMove, targetingAction: scene.targetingAction, selectedUsable: scene.selectedUsable, lockedTargetPreview: scene.lockedTargetPreview, attackIntents: scene.attackIntents.map((intent) => ({ ...intent })), actedUnitIds: new Set(scene.actedUnitIds), cameraTileX: scene.cameraTileX, cameraTileY: scene.cameraTileY, miniMapVisible: scene.miniMapVisible, rosterTab: scene.rosterTab, units: touchedUnits.map((unit) => ({ id: unit.id, x: unit.x, y: unit.y, hp: unit.hp, maxHp: unit.maxHp })) }; const fixtureViewportBounds = { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height }; const fixturePanelBounds = { x: scene.layout.panelX, y: scene.layout.panelY, width: scene.layout.panelWidth, height: scene.layout.panelHeight }; const fixtureTargetPosition = { x: 5, y: 15 }; const fixtureTargetTileBounds = () => ({ x: scene.tileTopLeftX(target.x), y: scene.tileTopLeftY(target.y), width: scene.layout.tileSize, height: scene.layout.tileSize }); const fixturePartnerTileBounds = () => ({ x: scene.tileTopLeftX(partner.x), y: scene.tileTopLeftY(partner.y), width: scene.layout.tileSize, height: scene.layout.tileSize }); const capture = () => { const state = scene.getDebugState(); return { phase: state.phase, selectedUnitId: state.selectedUnitId, selectedUsable: state.selectedUsable, preview: state.bondChainReadyPreview ?? null, targetTileBounds: fixtureTargetTileBounds(), partnerTileBounds: fixturePartnerTileBounds(), viewportBounds: fixtureViewportBounds, panelBounds: fixturePanelBounds }; }; const stageTargeting = ({ user, action, usable, intentAttackerId, intentTargetId, actedUnitId }) => { scene.clearMarkers(); scene.hideCommandMenu(); scene.selectedUnit = user; scene.pendingMove = undefined; scene.targetingAction = undefined; scene.selectedUsable = undefined; scene.lockedTargetPreview = undefined; scene.phase = 'command'; scene.attackIntents = [{ attackerId: intentAttackerId, targetId: intentTargetId }]; scene.actedUnitIds = new Set([actedUnitId]); scene.beginDamageTargeting(user, action, usable); }; let probes; try { attacker.x = 4; attacker.y = 15; partner.x = 3; partner.y = 15; target.x = fixtureTargetPosition.x; target.y = fixtureTargetPosition.y; target.maxHp = Math.max(target.maxHp, 5000); target.hp = target.maxHp; scene.centerCameraOnTile(target.x, target.y); touchedUnits.forEach((unit) => scene.positionUnitView(unit)); stageTargeting({ user: attacker, action: 'attack', intentAttackerId: partner.id, intentTargetId: target.id, actedUnitId: partner.id }); const eligibleCombatPreview = scene.combatPreview(attacker, target, 'attack'); scene.renderAttackPreview(eligibleCombatPreview, false); const eligible = { ...capture(), baseDamage: eligibleCombatPreview.damage, targetHp: target.hp }; const eligibleCamera = { x: scene.cameraTileX, y: scene.cameraTileY }; scene.cameraTileX = scene.maxCameraTileX(); scene.cameraTileY = 0; scene.updateCameraView(); const offscreen = capture(); scene.cameraTileX = eligibleCamera.x; scene.cameraTileY = eligibleCamera.y; scene.updateCameraView(); const returned = capture(); stageTargeting({ user: attacker, action: 'attack', intentAttackerId: partner.id, intentTargetId: mismatchTarget.id, actedUnitId: partner.id }); const mismatch = capture(); const damageStrategy = scene.availableUsables(partner, 'strategy').find((usable) => usable.effect === 'damage'); if (!damageStrategy) { throw new Error(`Expected ${partner.id} to have a damaging strategy for the pursuit-ready UI fixture.`); } stageTargeting({ user: partner, action: damageStrategy.command, usable: damageStrategy, intentAttackerId: attacker.id, intentTargetId: target.id, actedUnitId: attacker.id }); const strategy = capture(); partner.x = 3; partner.y = 15; scene.positionUnitView(partner); const lethalBaseDamage = scene.combatPreview(attacker, target, 'attack').damage; target.hp = Math.max(1, lethalBaseDamage); stageTargeting({ user: attacker, action: 'attack', intentAttackerId: partner.id, intentTargetId: target.id, actedUnitId: partner.id }); const lethal = { ...capture(), baseDamage: scene.combatPreview(attacker, target, 'attack').damage, targetHp: target.hp }; target.hp = target.maxHp; partner.x = 18; partner.y = 17; scene.positionUnitView(partner); stageTargeting({ user: attacker, action: 'attack', intentAttackerId: partner.id, intentTargetId: target.id, actedUnitId: partner.id }); const far = capture(); probes = { ready: true, attackerId: attacker.id, partnerId: partner.id, partnerName: partner.name, targetId: target.id, targetName: target.name, expectedRate: mechanics.chain?.rate ?? null, eligible, offscreen, returned, mismatch, strategy, lethal, far }; } finally { scene.clearMarkers(); scene.hideCommandMenu(); original.units.forEach((snapshot) => { const unit = scene.debugUnitById(snapshot.id); if (!unit) { return; } unit.x = snapshot.x; unit.y = snapshot.y; unit.hp = snapshot.hp; unit.maxHp = snapshot.maxHp; }); scene.attackIntents = original.attackIntents.map((intent) => ({ ...intent })); scene.actedUnitIds = new Set(original.actedUnitIds); scene.selectedUnit = original.selectedUnit; scene.pendingMove = original.pendingMove; scene.targetingAction = original.targetingAction; scene.selectedUsable = original.selectedUsable; scene.lockedTargetPreview = original.lockedTargetPreview; scene.phase = original.phase; scene.cameraTileX = original.cameraTileX; scene.cameraTileY = original.cameraTileY; scene.updateCameraView(); scene.refreshUnitLegibilityStyles(); scene.renderRosterPanel(original.rosterTab, '행동할 장수를 선택하세요.'); scene.setMiniMapVisible(original.miniMapVisible); } const restored = scene.getDebugState(); return { ...probes, restored: { phase: restored.phase, selectedUnitId: restored.selectedUnitId, commandMenuVisible: restored.commandMenuVisible, markerCount: restored.markerCount, actedUnitIds: [...restored.actedUnitIds].sort(), attackIntents: restored.attackIntents, camera: restored.camera, pursuitReady: restored.bondChainReadyPreview ?? null, units: restored.units .filter((unit) => original.units.some((snapshot) => snapshot.id === unit.id)) .map(({ id, x, y, hp, maxHp }) => ({ id, x, y, hp, maxHp })) .sort((left, right) => left.id.localeCompare(right.id)) }, expectedRestored: { phase: stateBefore.phase, selectedUnitId: stateBefore.selectedUnitId, commandMenuVisible: stateBefore.commandMenuVisible, markerCount: stateBefore.markerCount, actedUnitIds: [...stateBefore.actedUnitIds].sort(), attackIntents: stateBefore.attackIntents, camera: stateBefore.camera, units: original.units .map(({ id, x, y, hp, maxHp }) => ({ id, x, y, hp, maxHp })) .sort((left, right) => left.id.localeCompare(right.id)) } }; }); assert( firstBattleBondChainReadyUi.ready === true && firstBattleBondChainReadyUi.expectedRate === 18, `Expected a deterministic level-72 pursuit-ready UI fixture: ${JSON.stringify(firstBattleBondChainReadyUi)}` ); const readyCandidate = firstBattleBondChainReadyUi.eligible?.preview?.candidates?.[0]; const readyHud = firstBattleBondChainReadyUi.eligible?.preview?.hud; const readyContext = '명중 후 대상 생존 시 · 지원 거리 충족'; assert( firstBattleBondChainReadyUi.eligible?.phase === 'targeting' && firstBattleBondChainReadyUi.eligible.selectedUnitId === firstBattleBondChainReadyUi.attackerId && firstBattleBondChainReadyUi.eligible.selectedUsable === null && firstBattleBondChainReadyUi.eligible.preview?.visible === true && firstBattleBondChainReadyUi.eligible.preview.action === 'attack' && firstBattleBondChainReadyUi.eligible.preview.attackerId === firstBattleBondChainReadyUi.attackerId && sameJsonValue(firstBattleBondChainReadyUi.eligible.preview.eligibleTargetIds, [firstBattleBondChainReadyUi.targetId]) && firstBattleBondChainReadyUi.eligible.preview.candidates?.length === 1 && firstBattleBondChainReadyUi.eligible.baseDamage < firstBattleBondChainReadyUi.eligible.targetHp && readyCandidate?.targetId === firstBattleBondChainReadyUi.targetId && readyCandidate.targetName === firstBattleBondChainReadyUi.targetName && readyCandidate.partnerId === firstBattleBondChainReadyUi.partnerId && readyCandidate.partnerName === firstBattleBondChainReadyUi.partnerName && readyCandidate.rate === 18 && readyCandidate.damage > 0 && readyCandidate.text === `추격 후보 ${firstBattleBondChainReadyUi.partnerName} 18% · 예상 +${readyCandidate.damage}` && readyCandidate.context === readyContext && readyCandidate.targetMarkerId === `bond-chain-ready-${firstBattleBondChainReadyUi.targetId}` && readyCandidate.partnerMarkerId === `bond-chain-partner-${firstBattleBondChainReadyUi.partnerId}` && readyCandidate.targetMarkerVisible === true && readyCandidate.labelVisible === true && readyCandidate.partnerMarkerVisible === true, `Expected matching acted intent to expose exactly one 18% pursuit candidate with clear partner, damage, and survival context: ${JSON.stringify(firstBattleBondChainReadyUi.eligible)}` ); const returnedCandidate = firstBattleBondChainReadyUi.returned?.preview?.candidates?.[0]; assert( firstBattleBondChainReadyUi.offscreen?.preview?.visible === false && sameJsonValue(firstBattleBondChainReadyUi.offscreen.preview.eligibleTargetIds, []) && sameJsonValue(firstBattleBondChainReadyUi.offscreen.preview.candidates, []) && firstBattleBondChainReadyUi.offscreen.preview.hud === null && firstBattleBondChainReadyUi.offscreen.preview.focused === null && firstBattleBondChainReadyUi.returned?.preview?.visible === true && returnedCandidate?.targetId === readyCandidate.targetId && returnedCandidate.partnerId === readyCandidate.partnerId && returnedCandidate.damage === readyCandidate.damage && returnedCandidate.targetMarkerVisible === true && returnedCandidate.labelVisible === true && returnedCandidate.partnerMarkerVisible === true && firstBattleBondChainReadyUi.returned.preview.hud?.targetId === readyCandidate.targetId, `Expected camera movement to hide offscreen pursuit markers and restore the same visible target, partner, and HUD on return: ${JSON.stringify(firstBattleBondChainReadyUi)}` ); assert( isFiniteBounds(readyCandidate?.targetMarkerBounds) && isFiniteBounds(readyCandidate?.partnerMarkerBounds) && isFiniteBounds(readyCandidate?.labelBounds) && isFiniteBounds(readyCandidate?.tileBounds) && isFiniteBounds(firstBattleBondChainReadyUi.eligible?.targetTileBounds) && isFiniteBounds(firstBattleBondChainReadyUi.eligible?.partnerTileBounds) && sameJsonValue(readyCandidate.tileBounds, firstBattleBondChainReadyUi.eligible.targetTileBounds) && sameJsonValue(readyCandidate.bounds, readyCandidate.targetMarkerBounds) && boundsInside(readyCandidate.targetMarkerBounds, readyCandidate.tileBounds, 2) && boundsInside(readyCandidate.labelBounds, readyCandidate.targetMarkerBounds, 3) && boundsInside(readyCandidate.partnerMarkerBounds, firstBattleBondChainReadyUi.eligible.partnerTileBounds, 2) && boundsInside(readyCandidate.tileBounds, firstBattleBondChainReadyUi.eligible.viewportBounds) && boundsInside(firstBattleBondChainReadyUi.eligible.partnerTileBounds, firstBattleBondChainReadyUi.eligible.viewportBounds), `Expected pursuit target badge, label, and partner marker to remain inside their visible map tiles: ${JSON.stringify(firstBattleBondChainReadyUi.eligible)}` ); assert( readyHud?.targetId === readyCandidate.targetId && readyHud.partnerId === readyCandidate.partnerId && readyHud.partnerName === readyCandidate.partnerName && readyHud.rate === readyCandidate.rate && readyHud.damage === readyCandidate.damage && readyHud.locked === false && readyHud.text === readyCandidate.text && readyHud.context === readyContext && isFiniteBounds(readyHud.bounds) && isFiniteBounds(readyHud.textBounds) && isFiniteBounds(readyHud.contextBounds) && boundsInside(readyHud.bounds, firstBattleBondChainReadyUi.eligible.panelBounds) && boundsInside(readyHud.bounds, firstBattleBondChainReadyUi.eligible.viewportBounds) && boundsInside(readyHud.textBounds, readyHud.bounds, 2) && boundsInside(readyHud.contextBounds, readyHud.bounds, 2), `Expected the hovered target forecast to show the same two-line pursuit HUD entirely inside the side panel: ${JSON.stringify(firstBattleBondChainReadyUi.eligible)}` ); const hiddenReadyStates = [ ['mismatched target intent', firstBattleBondChainReadyUi.mismatch, 'attack', firstBattleBondChainReadyUi.attackerId, null], ['strategy action', firstBattleBondChainReadyUi.strategy, 'strategy', firstBattleBondChainReadyUi.partnerId, 'fireTactic'], ['lethal base attack', firstBattleBondChainReadyUi.lethal, 'attack', firstBattleBondChainReadyUi.attackerId, null], ['out-of-support-range partner', firstBattleBondChainReadyUi.far, 'attack', firstBattleBondChainReadyUi.attackerId, null] ]; assert( firstBattleBondChainReadyUi.lethal?.baseDamage >= firstBattleBondChainReadyUi.lethal?.targetHp, `Expected the lethal fixture to prove the base attack would defeat the target before pursuit: ${JSON.stringify(firstBattleBondChainReadyUi.lethal)}` ); hiddenReadyStates.forEach(([context, probe, action, attackerId, selectedUsable]) => { assert( probe?.phase === 'targeting' && probe.selectedUnitId === attackerId && probe.selectedUsable === selectedUsable && probe.preview?.visible === false && probe.preview.action === action && probe.preview.attackerId === attackerId && sameJsonValue(probe.preview.eligibleTargetIds, []) && sameJsonValue(probe.preview.candidates, []) && probe.preview.hud === null && sameJsonValue(probe.preview.markers, []) && probe.preview.focused === null, `Expected pursuit-ready UI to remain hidden for ${context} without stale markers or HUD: ${JSON.stringify(probe)}` ); }); const { pursuitReady: restoredPursuitReady, ...restoredFixtureState } = firstBattleBondChainReadyUi.restored; assert( sameJsonValue(restoredFixtureState, firstBattleBondChainReadyUi.expectedRestored) && restoredPursuitReady?.visible === false && restoredPursuitReady.action === null && restoredPursuitReady.attackerId === null && sameJsonValue(restoredPursuitReady.eligibleTargetIds, []) && sameJsonValue(restoredPursuitReady.candidates, []) && restoredPursuitReady.hud === null, `Expected the pursuit-ready UI fixture to restore battle positions, intents, acted state, camera, selection, markers, and idle HUD before the existing RC flow: ${JSON.stringify(firstBattleBondChainReadyUi)}` ); const firstBattleBondChainJudgement = await page.evaluate(async () => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const stateBefore = window.__HEROS_DEBUG__?.battle(); const mechanics = stateBefore?.combatMechanicsProbe; const attackerId = mechanics?.attackerId; const partnerId = mechanics?.partnerId; const targetId = mechanics?.enemyId; if ( !scene || !attackerId || !partnerId || !targetId || stateBefore?.phase !== 'idle' || stateBefore.selectedUnitId !== null || stateBefore.commandMenuVisible !== false ) { return { ready: false, reason: 'first battle was not in a clean idle state for the resonance judgement fixture', stateBefore, mechanics }; } const clone = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value)); const comparableSave = (state) => { const { savedAt: _savedAt, ...comparable } = state; return comparable; }; const collectText = (objects) => { const texts = []; const visited = new Set(); const visit = (object) => { if (!object || visited.has(object)) { return; } visited.add(object); if (object.type === 'Text' && typeof object.text === 'string') { texts.push(object.text); } if (Array.isArray(object.list)) { object.list.forEach(visit); } }; objects.forEach(visit); return texts; }; const sceneObjectsCreatedAfter = (before) => scene.children.list.filter((object) => !before.has(object)); const clearCreatedSceneObjects = (objects) => { objects.forEach((object) => { scene.tweens?.killTweensOf(object); if (object?.active) { object.destroy(); } }); }; const setDebugForceHit = (enabled) => { const url = new URL(window.location.href); if (enabled) { url.searchParams.set('debugForceHit', '1'); } else { url.searchParams.delete('debugForceHit'); } window.history.replaceState({}, '', url); }; const setDebugForceBondChain = (value) => { const url = new URL(window.location.href); if (value === null) { url.searchParams.delete('debugForceBondChain'); } else { url.searchParams.set('debugForceBondChain', value); } window.history.replaceState({}, '', url); }; const statsFor = (snapshot, unitId) => clone(snapshot?.[unitId] ?? {}); const statsDelta = (before, after) => Object.fromEntries( Array.from(new Set([...Object.keys(before), ...Object.keys(after)])).map((key) => [ key, (after[key] ?? 0) - (before[key] ?? 0) ]) ); const originalUrl = window.location.href; const originalSave = clone(scene.createBattleSaveState()); const originalCamera = { x: scene.cameraTileX, y: scene.cameraTileY }; const originalJudgement = clone(scene.bondChainJudgementLast); const originalPartnerClassKey = scene.debugUnitById(partnerId)?.classKey; const originalBattleSpeed = scene.battleSpeed; const originalFastForwardHeld = scene.fastForwardHeld; const originalRollPercent = scene.rollPercent; const hadOwnRollPercent = Object.prototype.hasOwnProperty.call(scene, 'rollPercent'); const originalPhase = scene.phase; const originalSelectedUnit = scene.selectedUnit; const originalPendingMove = scene.pendingMove; const originalTargetingAction = scene.targetingAction; const originalSelectedUsable = scene.selectedUsable; const originalLockedTargetPreview = scene.lockedTargetPreview; const restoreFixtureState = () => { scene.hideCombatCutIn(); scene.applyBattleSaveState(clone(originalSave)); scene.cameraTileX = originalCamera.x; scene.cameraTileY = originalCamera.y; scene.updateCameraView(); scene.phase = originalPhase; scene.selectedUnit = originalSelectedUnit; scene.pendingMove = originalPendingMove; scene.targetingAction = originalTargetingAction; scene.selectedUsable = originalSelectedUsable; scene.lockedTargetPreview = originalLockedTargetPreview; scene.bondChainJudgementLast = clone(originalJudgement); const partner = scene.debugUnitById(partnerId); if (partner && originalPartnerClassKey) { partner.classKey = originalPartnerClassKey; } scene.battleSpeed = originalBattleSpeed; scene.fastForwardHeld = originalFastForwardHeld; scene.renderBattleSpeedControl(); scene.clearMarkers(); scene.hideCommandMenu(); }; const prepareScenario = (mode, partnerClassKey) => { restoreFixtureState(); const attacker = scene.debugUnitById(attackerId); const partner = scene.debugUnitById(partnerId); const target = scene.debugUnitById(targetId); if (!attacker || !partner || !target) { throw new Error(`Missing judgement fixture units: ${JSON.stringify({ attackerId, partnerId, targetId })}`); } if (partnerClassKey) { partner.classKey = partnerClassKey; } attacker.x = 4; attacker.y = 15; partner.x = 3; partner.y = 15; target.x = 5; target.y = 15; target.maxHp = Math.max(target.maxHp, 5000); target.hp = target.maxHp; scene.attackIntents = [{ attackerId: partner.id, targetId: target.id }]; scene.actedUnitIds = new Set([partner.id]); scene.centerCameraOnTile(target.x, target.y); [attacker, partner, target].forEach((unit) => scene.positionUnitView(unit)); const preview = scene.combatPreview(attacker, target, 'attack'); if (!(preview.damage > 0 && preview.bondChainDamage > 0 && preview.bondChainRate > 0)) { throw new Error(`Expected a live resonance preview in ${mode}: ${JSON.stringify({ damage: preview.damage, rate: preview.bondChainRate, chainDamage: preview.bondChainDamage, partnerId: preview.bondChainPartnerId })}`); } target.hp = mode === 'lethal' ? preview.damage : preview.damage + preview.bondChainDamage; return { attacker, partner, target, preview }; }; const captureNeutralMapCue = async (result) => { const before = new Set(scene.children.list); const effect = scene.showBondMapEffect(result); const created = sceneObjectsCreatedAfter(before); const texts = collectText(created); await effect; return texts; }; const captureMapResult = (result) => { const before = new Set(scene.children.list); scene.showCombatMapResult(result); const created = sceneObjectsCreatedAfter(before); const texts = collectText(created); clearCreatedSceneObjects(created); return texts; }; const presentCombatResult = async (result) => { let presented = null; let settled = false; const telemetryStartedAt = performance.now(); const telemetry = { gauge: [], impact: [], partnerMotion: [] }; const originalAnimateCombatGauge = scene.animateCombatGauge; const originalShowCombatImpact = scene.showCombatImpact; const originalPlayCombatActionMotion = scene.playCombatActionMotion; const methodOwnership = { animateCombatGauge: Object.prototype.hasOwnProperty.call(scene, 'animateCombatGauge'), showCombatImpact: Object.prototype.hasOwnProperty.call(scene, 'showCombatImpact'), playCombatActionMotion: Object.prototype.hasOwnProperty.call(scene, 'playCombatActionMotion') }; const elapsed = () => performance.now() - telemetryStartedAt; const isPartnerResult = (candidate) => Boolean(result.bondChain) && candidate?.attacker?.id === result.bondChain.partnerId; const restoreMethod = (name, original) => { if (methodOwnership[name]) { scene[name] = original; } else { delete scene[name]; } }; scene.animateCombatGauge = function (fill, from, to, duration) { const isChainGauge = Boolean(result.bondChain) && duration === 300 && Math.abs(from - result.bondChain.previousDefenderHp / result.defender.maxHp) < 0.0001 && Math.abs(to - result.defender.hp / result.defender.maxHp) < 0.0001; const event = isChainGauge ? { startAt: elapsed(), endAt: null, from, to, duration, startScale: fill.scaleX, endScale: null } : null; if (event) { telemetry.gauge.push(event); } const animation = originalAnimateCombatGauge.call(this, fill, from, to, duration); return animation.then(() => { if (event) { event.endAt = elapsed(); event.endScale = fill.scaleX; } }); }; scene.showCombatImpact = function (impactResult, ...args) { if (isPartnerResult(impactResult)) { telemetry.impact.push({ at: elapsed(), damage: impactResult.damage }); } return originalShowCombatImpact.call(this, impactResult, ...args); }; scene.playCombatActionMotion = async function (motionResult, ...args) { const isPartnerMotion = isPartnerResult(motionResult); const event = isPartnerMotion ? { startAt: elapsed(), endAt: null } : null; if (event) { telemetry.partnerMotion.push(event); } await originalPlayCombatActionMotion.call(this, motionResult, ...args); if (event) { event.endAt = elapsed(); } }; try { const playback = scene.playCombatCutIn(result).finally(() => { settled = true; }); for (let attempt = 0; attempt < 240 && !settled; attempt += 1) { const judgement = scene.getDebugState().bondChainJudgement; if (judgement?.presented && !judgement.completed) { presented = clone(judgement); break; } await new Promise((resolve) => window.setTimeout(resolve, 25)); } await playback; return { presented, completed: clone(scene.getDebugState().bondChainJudgement), telemetry }; } finally { restoreMethod('animateCombatGauge', originalAnimateCombatGauge); restoreMethod('showCombatImpact', originalShowCombatImpact); restoreMethod('playCombatActionMotion', originalPlayCombatActionMotion); } }; const runScenario = async ({ mode, forcedRoll, present, naturalRoll = false, partnerClassKey, battleSpeed }) => { const { attacker, partner, target, preview } = prepareScenario(mode, partnerClassKey); if (battleSpeed) { scene.battleSpeed = battleSpeed; scene.fastForwardHeld = false; scene.renderBattleSpeedControl(); } setDebugForceHit(mode !== 'miss'); const rollCalls = []; const chainRollCalls = []; const originalBondChainRollSucceeded = scene.bondChainRollSucceeded; const hadOwnBondChainRollSucceeded = Object.prototype.hasOwnProperty.call(scene, 'bondChainRollSucceeded'); scene.rollPercent = (rate) => { rollCalls.push(rate); return naturalRoll && rate === preview.bondChainRate; }; scene.bondChainRollSucceeded = function (rate, forcedResult) { const rollCountBefore = rollCalls.length; const succeeded = originalBondChainRollSucceeded.call(this, rate, forcedResult); chainRollCalls.push({ rate, forcedResult: forcedResult ?? null, rollPercentCalls: rollCalls.length - rollCountBefore, succeeded }); return succeeded; }; const statsBefore = clone(scene.serializeBattleStats()); try { const result = scene.resolveCombatAction(attacker, target, 'attack', false, undefined, forcedRoll); const resolutionChainRollCalls = clone(chainRollCalls); const statsAfter = clone(scene.serializeBattleStats()); const outcomeChip = clone(scene.combatOutcomeExtraChip(result)); const mapCueLabel = scene.bondChainMapCueLabel(result); const neutralMapTexts = present ? await captureNeutralMapCue(result) : []; let presentationDurationMs = null; let judgement; if (present) { const presentationStartedAt = performance.now(); judgement = await presentCombatResult(result); presentationDurationMs = performance.now() - presentationStartedAt; } else { judgement = { presented: null, completed: clone(scene.getDebugState().bondChainJudgement) }; } const formatted = scene.formatCombatResult(result); const mapTitle = scene.combatMapDamageTitle(result); const mapResultTexts = present ? captureMapResult(result) : []; return { mode, attackerId: attacker.id, partnerId: partner.id, partnerName: partner.name, partnerClassKey: partner.classKey, battleSpeed: scene.battleSpeed, presentationDurationMs, targetId: target.id, preview: { damage: preview.damage, chainRate: preview.bondChainRate, chainDamage: preview.bondChainDamage, chainPartnerId: preview.bondChainPartnerId ?? null }, initialTargetHp: result.previousDefenderHp, finalTargetHp: result.defender.hp, hit: result.hit, baseDefeated: result.baseDefeated, defeated: result.defeated, damage: result.damage, attempt: clone(result.bondChainAttempt) ?? null, chain: clone(result.bondChain) ?? null, outcomeChip, mapCueLabel, neutralMapTexts, formatted, mapTitle, mapResultTexts, judgement, chainRollCalls: resolutionChainRollCalls, partnerStatsDelta: statsDelta(statsFor(statsBefore, partner.id), statsFor(statsAfter, partner.id)), targetStatsDelta: statsDelta(statsFor(statsBefore, target.id), statsFor(statsAfter, target.id)) }; } finally { if (hadOwnRollPercent) { scene.rollPercent = originalRollPercent; } else { delete scene.rollPercent; } if (hadOwnBondChainRollSucceeded) { scene.bondChainRollSucceeded = originalBondChainRollSucceeded; } else { delete scene.bondChainRollSucceeded; } } }; const runProductionScenario = async (shouldDefeat) => { const { attacker, target, preview } = prepareScenario('success'); if (!shouldDefeat) { target.hp += Math.max(20, preview.bondChainDamage); } const targetView = scene.unitViews.get(target.id); const targetMiniMapDot = scene.miniMapUnitDots.get(target.id); if (!targetView) { throw new Error(`Missing production finisher target view: ${target.id}`); } const viewState = () => ({ spriteVisible: targetView.sprite.visible, spriteAlpha: targetView.sprite.alpha, labelVisible: targetView.label.visible, labelAlpha: targetView.label.alpha, hitZoneVisible: targetView.hitZone.visible, miniMapDotVisible: targetMiniMapDot?.visible ?? null }); const originalForceBondChain = new URL(originalUrl).searchParams.get('debugForceBondChain'); const originalResolveCombatAction = scene.resolveCombatAction; const originalPlayCombatCutIn = scene.playCombatCutIn; const originalApplyDefeatedStyle = scene.applyDefeatedStyle; const originalResolveCounterAttack = scene.resolveCounterAttack; const methodOwnership = { resolveCombatAction: Object.prototype.hasOwnProperty.call(scene, 'resolveCombatAction'), playCombatCutIn: Object.prototype.hasOwnProperty.call(scene, 'playCombatCutIn'), applyDefeatedStyle: Object.prototype.hasOwnProperty.call(scene, 'applyDefeatedStyle'), resolveCounterAttack: Object.prototype.hasOwnProperty.call(scene, 'resolveCounterAttack') }; let primaryResult = null; let counterResult = null; let visibleAfterResolve = null; let visibleAtCutInStart = null; let visibleAtCutInEnd = null; let primaryCutInStarted = false; let primaryCutInCompleted = false; let counterCalls = 0; let counterProduced = false; const cutInOrder = []; const defeatApplications = []; const restoreMethod = (name, original) => { if (methodOwnership[name]) { scene[name] = original; } else { delete scene[name]; } }; setDebugForceHit(true); setDebugForceBondChain('1'); scene.rollPercent = () => false; scene.resolveCombatAction = function (...args) { const result = originalResolveCombatAction.apply(this, args); if (args[3] === true) { counterResult = result; } else { primaryResult = result; visibleAfterResolve = viewState(); } return result; }; scene.playCombatCutIn = async function (result) { const isPrimary = result === primaryResult; cutInOrder.push(isPrimary ? 'primary' : result === counterResult || result.isCounter ? 'counter' : 'other'); if (isPrimary) { primaryCutInStarted = true; visibleAtCutInStart = viewState(); } await originalPlayCombatCutIn.call(this, result); if (isPrimary) { primaryCutInCompleted = true; visibleAtCutInEnd = viewState(); } }; scene.applyDefeatedStyle = function (unit, view) { if (unit.id === target.id) { defeatApplications.push({ primaryCutInStarted, primaryCutInCompleted, before: viewState() }); } return originalApplyDefeatedStyle.call(this, unit, view); }; scene.resolveCounterAttack = function (result) { counterCalls += 1; const counter = originalResolveCounterAttack.call(this, result); counterProduced = counterProduced || Boolean(counter); return counter; }; try { scene.phase = 'targeting'; scene.selectedUnit = attacker; await scene.tryResolveDamageTarget(attacker, target, 'attack'); return { result: primaryResult ? { hit: primaryResult.hit, baseDefeated: primaryResult.baseDefeated, defeated: primaryResult.defeated, chainDefeated: primaryResult.bondChain?.defeated ?? false, counter: Boolean(primaryResult.counter) } : null, visibleAfterResolve, visibleAtCutInStart, visibleAtCutInEnd, defeatApplications, visibleAfterWrapper: viewState(), counterCalls, counterProduced, cutInOrder }; } finally { restoreMethod('resolveCombatAction', originalResolveCombatAction); restoreMethod('playCombatCutIn', originalPlayCombatCutIn); restoreMethod('applyDefeatedStyle', originalApplyDefeatedStyle); restoreMethod('resolveCounterAttack', originalResolveCounterAttack); if (hadOwnRollPercent) { scene.rollPercent = originalRollPercent; } else { delete scene.rollPercent; } setDebugForceBondChain(originalForceBondChain); } }; let scenarios; let restored; try { const productionFinisher = await runProductionScenario(true); const productionSurvivor = await runProductionScenario(false); const success = await runScenario({ mode: 'success', forcedRoll: true, present: true }); const failure = await runScenario({ mode: 'failure', forcedRoll: false, present: true }); const rangedNormal = await runScenario({ mode: 'success', forcedRoll: true, present: true, partnerClassKey: 'archer', battleSpeed: 'normal' }); const rangedFast = await runScenario({ mode: 'success', forcedRoll: true, present: true, partnerClassKey: 'strategist', battleSpeed: 'fast' }); const rangedVeryFast = await runScenario({ mode: 'success', forcedRoll: true, present: true, partnerClassKey: 'strategist', battleSpeed: 'very-fast' }); const natural = await runScenario({ mode: 'success', forcedRoll: undefined, present: false, naturalRoll: true }); const naturalFailure = await runScenario({ mode: 'failure', forcedRoll: undefined, present: false }); const miss = await runScenario({ mode: 'miss', forcedRoll: true, present: false }); const lethal = await runScenario({ mode: 'lethal', forcedRoll: true, present: false }); scenarios = { productionFinisher, productionSurvivor, success, failure, rangedNormal, rangedFast, rangedVeryFast, natural, naturalFailure, miss, lethal }; } finally { // Let the final lethal scenario's map popup and delayed defeat styling settle // before restoring the live battle. Otherwise those callbacks can fire after // restoration and hide the revived fixture target in the following RC flow. await new Promise((resolve) => window.setTimeout(resolve, 900)); restoreFixtureState(); window.history.replaceState({}, '', originalUrl); const restoredState = scene.getDebugState(); restored = { save: comparableSave(clone(scene.createBattleSaveState())), camera: { x: scene.cameraTileX, y: scene.cameraTileY }, phase: restoredState.phase, selectedUnitId: restoredState.selectedUnitId, pendingMove: restoredState.pendingMove, selectedUsable: restoredState.selectedUsable, commandMenuVisible: restoredState.commandMenuVisible, markerCount: restoredState.markerCount, judgement: clone(restoredState.bondChainJudgement), partnerClassKey: scene.debugUnitById(partnerId)?.classKey ?? null, battleSpeed: scene.battleSpeed, fastForwardHeld: scene.fastForwardHeld, combatObjectCount: scene.combatCutInObjects.length, debugForceHit: new URLSearchParams(window.location.search).get('debugForceHit') }; } return { ready: true, sceneBounds: { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height }, expectedRate: mechanics.chain?.rate ?? null, scenarios, restored, expectedRestored: { save: comparableSave(originalSave), camera: originalCamera, phase: stateBefore.phase, selectedUnitId: stateBefore.selectedUnitId, pendingMove: stateBefore.pendingMove, selectedUsable: stateBefore.selectedUsable, commandMenuVisible: stateBefore.commandMenuVisible, markerCount: stateBefore.markerCount, judgement: originalJudgement ?? null, partnerClassKey: originalPartnerClassKey ?? null, battleSpeed: originalBattleSpeed, fastForwardHeld: originalFastForwardHeld, combatObjectCount: 0, debugForceHit: new URL(originalUrl).searchParams.get('debugForceHit') } }; }); assert( firstBattleBondChainJudgement.ready === true && firstBattleBondChainJudgement.expectedRate === 18, `Expected a deterministic level-72 resonance judgement fixture: ${JSON.stringify(firstBattleBondChainJudgement)}` ); const productionFinisher = firstBattleBondChainJudgement.scenarios.productionFinisher; const productionViewVisible = (view) => view?.spriteVisible === true && view.spriteAlpha > 0.9 && view.labelVisible === true && view.labelAlpha > 0.9; assert( productionFinisher.result?.hit === true && productionFinisher.result.baseDefeated === false && productionFinisher.result.defeated === true && productionFinisher.result.chainDefeated === true && productionViewVisible(productionFinisher.visibleAfterResolve) && productionViewVisible(productionFinisher.visibleAtCutInStart) && productionViewVisible(productionFinisher.visibleAtCutInEnd) && productionFinisher.visibleAfterResolve.miniMapDotVisible === true && productionFinisher.visibleAtCutInStart.miniMapDotVisible === true && productionFinisher.visibleAtCutInEnd.miniMapDotVisible === true && productionFinisher.defeatApplications.length >= 1 && productionFinisher.defeatApplications.every((application) => application.primaryCutInStarted === true && application.primaryCutInCompleted === true ) && productionViewVisible(productionFinisher.defeatApplications[0].before) && productionFinisher.visibleAfterWrapper.spriteVisible === false && productionFinisher.visibleAfterWrapper.spriteAlpha === 0 && productionFinisher.visibleAfterWrapper.labelVisible === false && productionFinisher.visibleAfterWrapper.miniMapDotVisible === false && productionFinisher.counterCalls === 1 && productionFinisher.counterProduced === false && productionFinisher.result.counter === false && JSON.stringify(productionFinisher.cutInOrder) === JSON.stringify(['primary']), `Expected the production attack path to keep a pursuit-defeated target visible through the finisher, then hide it and suppress counterattack: ${JSON.stringify(productionFinisher)}` ); const productionSurvivor = firstBattleBondChainJudgement.scenarios.productionSurvivor; assert( productionSurvivor.result?.hit === true && productionSurvivor.result.baseDefeated === false && productionSurvivor.result.defeated === false && productionSurvivor.result.chainDefeated === false && productionSurvivor.result.counter === true && productionViewVisible(productionSurvivor.visibleAfterResolve) && productionViewVisible(productionSurvivor.visibleAtCutInStart) && productionViewVisible(productionSurvivor.visibleAtCutInEnd) && productionSurvivor.defeatApplications.length === 0 && productionViewVisible(productionSurvivor.visibleAfterWrapper) && productionSurvivor.counterCalls === 1 && productionSurvivor.counterProduced === true && JSON.stringify(productionSurvivor.cutInOrder) === JSON.stringify(['primary', 'counter']), `Expected the production nonlethal pursuit path to preserve the defender and continue into its counterattack cut-in: ${JSON.stringify(productionSurvivor)}` ); const judgementSuccess = firstBattleBondChainJudgement.scenarios.success; const successAttempt = judgementSuccess.attempt; const successChain = judgementSuccess.chain; const successPresented = judgementSuccess.judgement.presented; const successCompleted = judgementSuccess.judgement.completed; const expectedSuccessMotion = judgementSuccess.partnerClassKey === 'archer' ? 'ranged' : 'melee'; const neutralSuccessCue = [judgementSuccess.outcomeChip?.label, judgementSuccess.mapCueLabel, ...judgementSuccess.neutralMapTexts] .filter(Boolean) .join(' · '); assert( judgementSuccess.hit === true && judgementSuccess.baseDefeated === false && judgementSuccess.defeated === true && successAttempt?.succeeded === true && successAttempt.partnerId === judgementSuccess.partnerId && successAttempt.partnerName === judgementSuccess.partnerName && successAttempt.rate === 18 && successAttempt.expectedDamage === judgementSuccess.preview.chainDamage && successChain?.partnerId === successAttempt.partnerId && successChain.partnerName === successAttempt.partnerName && successChain.rate === successAttempt.rate && successChain.damage === successAttempt.expectedDamage && successChain.previousDefenderHp === successChain.damage && successChain.defeated === true && judgementSuccess.initialTargetHp === judgementSuccess.damage + successChain.damage && judgementSuccess.finalTargetHp === 0, `Expected forced pursuit success to preserve the attempt, full supporter damage, and follow-up defeat contract: ${JSON.stringify(judgementSuccess)}` ); assert( judgementSuccess.partnerStatsDelta.damageDealt === successChain.damage && judgementSuccess.partnerStatsDelta.defeats === 1 && judgementSuccess.partnerStatsDelta.actions === 0 && judgementSuccess.targetStatsDelta.damageTaken === judgementSuccess.damage + successChain.damage, `Expected successful pursuit damage and defeat credit to belong only to the supporter while the defender receives both hits: ${JSON.stringify(judgementSuccess)}` ); assert( judgementSuccess.outcomeChip?.label === '판정 18%' && judgementSuccess.mapCueLabel === `공명 판정 · ${judgementSuccess.partnerName} 18%` && judgementSuccess.neutralMapTexts.some((text) => text.includes(judgementSuccess.mapCueLabel)) && !neutralSuccessCue.includes('추격') && !neutralSuccessCue.includes('격파') && !neutralSuccessCue.includes('호흡 일치') && !neutralSuccessCue.includes('호흡 불발'), `Expected successful pursuit to remain a neutral percentage judgement in the outcome chip and pre-contact map cue: ${JSON.stringify(judgementSuccess)}` ); assert( successPresented?.status === 'success' && successPresented.partnerId === judgementSuccess.partnerId && successPresented.partnerName === judgementSuccess.partnerName && successPresented.rate === 18 && successPresented.expectedDamage === successChain.damage && successPresented.damage === successChain.damage && successPresented.defeated === true && successPresented.presented === true && successPresented.completed === false && successPresented.motion === expectedSuccessMotion && successPresented.title === '공명 판정 18%' && successPresented.detail === `${judgementSuccess.partnerName} · 호흡 일치` && isFiniteBounds(successPresented.bounds) && boundsInside(successPresented.bounds, firstBattleBondChainJudgement.sceneBounds), `Expected the successful judgement to expose the bounded percentage verdict before revealing pursuit contact: ${JSON.stringify(judgementSuccess.judgement)}` ); assert( successCompleted?.status === 'success' && successCompleted.presented === true && successCompleted.completed === true && successCompleted.motion === expectedSuccessMotion && successCompleted.damage === successChain.damage && successCompleted.defeated === true && successCompleted.title.includes('공명 격파') && isFiniteBounds(successCompleted.bounds) && boundsInside(successCompleted.bounds, firstBattleBondChainJudgement.sceneBounds) && judgementSuccess.formatted.includes(`공명 판정 · ${judgementSuccess.partnerName} 18% · 호흡 일치`) && judgementSuccess.formatted.includes(`추격 피해 +${successChain.damage} · 공명 격파`) && judgementSuccess.mapTitle.includes('공명 격파') && judgementSuccess.mapResultTexts.some((text) => text.includes('공명 격파')), `Expected successful pursuit contact to retain its completed debug snapshot and explicit finisher result feedback: ${JSON.stringify(judgementSuccess)}` ); const rangedScenarios = [ firstBattleBondChainJudgement.scenarios.rangedNormal, firstBattleBondChainJudgement.scenarios.rangedFast, firstBattleBondChainJudgement.scenarios.rangedVeryFast ]; const rangedTelemetryIsSynchronized = (scenario) => { const gauge = scenario.judgement.telemetry?.gauge?.[0]; const impact = scenario.judgement.telemetry?.impact?.[0]; const motion = scenario.judgement.telemetry?.partnerMotion?.[0]; return scenario.judgement.telemetry?.gauge?.length === 1 && scenario.judgement.telemetry?.impact?.length === 1 && scenario.judgement.telemetry?.partnerMotion?.length === 1 && gauge.duration === 300 && Math.abs(gauge.startScale - gauge.from) < 0.001 && Math.abs(gauge.endScale - gauge.to) < 0.001 && gauge.endAt > gauge.startAt && impact.damage === scenario.chain.damage && Math.abs(impact.at - gauge.startAt) < 80 && motion.startAt <= gauge.startAt && motion.endAt + 80 >= gauge.startAt; }; const rangedGaugeDurations = rangedScenarios.map((scenario) => { const gauge = scenario.judgement.telemetry.gauge[0]; return gauge.endAt - gauge.startAt; }); assert( rangedScenarios.map((scenario) => scenario.battleSpeed).join(',') === 'normal,fast,very-fast' && rangedScenarios[0].partnerClassKey === 'archer' && rangedScenarios[1].partnerClassKey === 'strategist' && rangedScenarios[2].partnerClassKey === 'strategist' && rangedScenarios.every((scenario) => scenario.attempt?.succeeded === true && scenario.judgement.presented?.status === 'success' && scenario.judgement.presented.motion === 'ranged' && scenario.judgement.presented.title === '공명 판정 18%' && scenario.judgement.completed?.completed === true && scenario.judgement.completed.motion === 'ranged' && rangedTelemetryIsSynchronized(scenario) && isFiniteBounds(scenario.judgement.completed.bounds) && boundsInside(scenario.judgement.completed.bounds, firstBattleBondChainJudgement.sceneBounds) && Number.isFinite(scenario.presentationDurationMs) && scenario.presentationDurationMs > 0 ) && rangedScenarios[0].presentationDurationMs > rangedScenarios[1].presentationDurationMs && rangedScenarios[1].presentationDurationMs > rangedScenarios[2].presentationDurationMs && rangedGaugeDurations[0] > rangedGaugeDurations[1] && rangedGaugeDurations[1] > rangedGaugeDurations[2], `Expected archer and extended-range pursuit motions to complete in all three battle speeds with ordered scaled timing: ${JSON.stringify(rangedScenarios)}` ); const judgementNatural = firstBattleBondChainJudgement.scenarios.natural; assert( judgementNatural.hit === true && judgementNatural.baseDefeated === false && judgementNatural.attempt?.succeeded === true && judgementNatural.chain?.damage === judgementNatural.preview.chainDamage && judgementNatural.chainRollCalls.length === 1 && judgementNatural.chainRollCalls[0].rate === judgementNatural.preview.chainRate && judgementNatural.chainRollCalls[0].forcedResult === null && judgementNatural.chainRollCalls[0].rollPercentCalls === 1 && judgementNatural.chainRollCalls[0].succeeded === true, `Expected the natural pursuit path to make exactly one unforced percentage roll after the base hit: ${JSON.stringify(judgementNatural)}` ); const judgementNaturalFailure = firstBattleBondChainJudgement.scenarios.naturalFailure; assert( judgementNaturalFailure.hit === true && judgementNaturalFailure.baseDefeated === false && judgementNaturalFailure.attempt?.succeeded === false && judgementNaturalFailure.chain === null && judgementNaturalFailure.chainRollCalls.length === 1 && judgementNaturalFailure.chainRollCalls[0].rate === judgementNaturalFailure.preview.chainRate && judgementNaturalFailure.chainRollCalls[0].forcedResult === null && judgementNaturalFailure.chainRollCalls[0].rollPercentCalls === 1 && judgementNaturalFailure.chainRollCalls[0].succeeded === false, `Expected the natural failed pursuit path to consume one unforced roll without applying follow-up damage: ${JSON.stringify(judgementNaturalFailure)}` ); const judgementFailure = firstBattleBondChainJudgement.scenarios.failure; const failureAttempt = judgementFailure.attempt; const failurePresented = judgementFailure.judgement.presented; const failureCompleted = judgementFailure.judgement.completed; const expectedFailureMotion = judgementFailure.partnerClassKey === 'archer' ? 'ranged' : 'melee'; const failureLine = `공명 판정 · ${judgementFailure.partnerName} 18% · 호흡 불발`; const neutralFailureCue = [judgementFailure.outcomeChip?.label, judgementFailure.mapCueLabel, ...judgementFailure.neutralMapTexts] .filter(Boolean) .join(' · '); assert( judgementFailure.hit === true && judgementFailure.baseDefeated === false && judgementFailure.defeated === false && failureAttempt?.succeeded === false && failureAttempt.partnerId === judgementFailure.partnerId && failureAttempt.partnerName === judgementFailure.partnerName && failureAttempt.rate === 18 && failureAttempt.expectedDamage === judgementFailure.preview.chainDamage && judgementFailure.chain === null && judgementFailure.finalTargetHp === judgementFailure.initialTargetHp - judgementFailure.damage, `Expected forced pursuit failure to retain the attempted partner and rate without applying follow-up damage: ${JSON.stringify(judgementFailure)}` ); assert( judgementFailure.partnerStatsDelta.damageDealt === 0 && judgementFailure.partnerStatsDelta.defeats === 0 && judgementFailure.partnerStatsDelta.actions === 0 && judgementFailure.targetStatsDelta.damageTaken === judgementFailure.damage, `Expected failed pursuit to leave supporter and defender pursuit statistics untouched: ${JSON.stringify(judgementFailure)}` ); assert( judgementFailure.outcomeChip?.label === '판정 18%' && judgementFailure.mapCueLabel === `공명 판정 · ${judgementFailure.partnerName} 18%` && judgementFailure.neutralMapTexts.some((text) => text.includes(judgementFailure.mapCueLabel)) && !neutralFailureCue.includes('추격') && !neutralFailureCue.includes('격파') && !neutralFailureCue.includes('호흡 불발') && judgementFailure.formatted.includes(failureLine) && judgementFailure.mapResultTexts.some((text) => text.includes(failureLine)), `Expected failed pursuit to keep the pre-contact cue neutral and then name the partner, rate, and exact failure reason: ${JSON.stringify(judgementFailure)}` ); assert( failurePresented?.status === 'failed' && failurePresented.partnerId === judgementFailure.partnerId && failurePresented.partnerName === judgementFailure.partnerName && failurePresented.rate === 18 && failurePresented.expectedDamage === judgementFailure.preview.chainDamage && failurePresented.damage === 0 && failurePresented.defeated === false && failurePresented.presented === true && failurePresented.completed === false && failurePresented.motion === expectedFailureMotion && `${failurePresented.title} ${failurePresented.detail}`.includes('호흡 불발') && isFiniteBounds(failurePresented.bounds) && boundsInside(failurePresented.bounds, firstBattleBondChainJudgement.sceneBounds), `Expected the failed judgement to expose one bounded in-progress non-damaging presentation: ${JSON.stringify(judgementFailure.judgement)}` ); assert( failureCompleted?.status === 'failed' && failureCompleted.presented === true && failureCompleted.completed === true && failureCompleted.motion === expectedFailureMotion && failureCompleted.damage === 0 && failureCompleted.defeated === false && `${failureCompleted.title} ${failureCompleted.detail}`.includes('호흡 불발') && isFiniteBounds(failureCompleted.bounds) && boundsInside(failureCompleted.bounds, firstBattleBondChainJudgement.sceneBounds), `Expected failed pursuit presentation to complete once while retaining its judgement payload: ${JSON.stringify(judgementFailure.judgement)}` ); const judgementMiss = firstBattleBondChainJudgement.scenarios.miss; const judgementLethal = firstBattleBondChainJudgement.scenarios.lethal; assert( judgementMiss.hit === false && judgementMiss.damage === 0 && judgementMiss.attempt === null && judgementMiss.chain === null && judgementMiss.finalTargetHp === judgementMiss.initialTargetHp && judgementMiss.judgement.completed === null, `Expected a missed base attack to skip resonance judgement entirely: ${JSON.stringify(judgementMiss)}` ); assert( judgementLethal.hit === true && judgementLethal.baseDefeated === true && judgementLethal.defeated === true && judgementLethal.attempt === null && judgementLethal.chain === null && judgementLethal.finalTargetHp === 0 && judgementLethal.judgement.completed === null, `Expected a lethal base attack to skip resonance judgement entirely: ${JSON.stringify(judgementLethal)}` ); assert( sameJsonValue(firstBattleBondChainJudgement.restored, firstBattleBondChainJudgement.expectedRestored), `Expected the judgement fixture to restore the full saveable battle state, camera, URL flags, selection, markers, cut-in objects, and prior debug snapshot: ${JSON.stringify(firstBattleBondChainJudgement)}` ); const firstBattleCombatCutInStage = await page.evaluate(async () => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const stateBefore = scene?.getDebugState?.(); const mechanics = stateBefore?.combatMechanicsProbe; const attackerId = mechanics?.attackerId; const targetId = mechanics?.enemyId; if ( !scene || !attackerId || !targetId || stateBefore?.phase !== 'idle' || stateBefore.selectedUnitId !== null || stateBefore.commandMenuVisible !== false ) { return { ready: false, reason: 'first battle was not in a clean idle state for the map-backed combat cut-in fixture', stateBefore, mechanics }; } const clone = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value)); const comparableSave = (state) => { const { savedAt: _savedAt, ...comparable } = state; return comparable; }; const attacker = scene.debugUnitById(attackerId); const target = scene.debugUnitById(targetId); if (!attacker || !target) { return { ready: false, reason: 'map-backed combat cut-in fixture units were missing', attackerId, targetId }; } const originalUrl = window.location.href; const originalSave = clone(scene.createBattleSaveState()); const originalCamera = { x: scene.cameraTileX, y: scene.cameraTileY }; const originalPhase = scene.phase; const originalSelectedUnit = scene.selectedUnit; const originalPendingMove = scene.pendingMove; const originalTargetingAction = scene.targetingAction; const originalSelectedUsable = scene.selectedUsable; const originalLockedTargetPreview = scene.lockedTargetPreview; const originalBattleSpeed = scene.battleSpeed; const originalFastForwardHeld = scene.fastForwardHeld; const originalJudgement = clone(scene.bondChainJudgementLast); const originalCutInActive = clone(scene.combatCutInStageActive); const originalCutInLast = clone(scene.combatCutInStageLast); const originalDelay = scene.delay; const hadOwnDelay = Object.prototype.hasOwnProperty.call(scene, 'delay'); const expectedRestored = { save: comparableSave(clone(originalSave)), camera: { ...originalCamera }, phase: stateBefore.phase, selectedUnitId: stateBefore.selectedUnitId, pendingMove: clone(stateBefore.pendingMove), selectedUsable: stateBefore.selectedUsable, commandMenuVisible: stateBefore.commandMenuVisible, markerCount: stateBefore.markerCount, combatCutIn: clone(stateBefore.combatCutIn) }; attacker.x = 4; attacker.y = 15; target.x = 5; target.y = 15; target.maxHp = Math.max(target.maxHp, 500); target.hp = target.maxHp; scene.attackIntents = []; scene.actedUnitIds = new Set(); scene.centerCameraOnTile(target.x, target.y); scene.positionUnitView(attacker); scene.positionUnitView(target); const fixtureUrl = new URL(window.location.href); fixtureUrl.searchParams.set('debugForceHit', '1'); window.history.replaceState({}, '', fixtureUrl); const result = scene.resolveCombatAction(attacker, target, 'attack', false, undefined, false); let releaseDelay; let gateCalls = 0; const delayGate = new Promise((resolve) => { releaseDelay = resolve; }); scene.battleSpeed = 'very-fast'; scene.fastForwardHeld = false; scene.delay = () => { gateCalls += 1; return delayGate; }; const playback = scene.playCombatCutIn(result).then( () => ({ ok: true, error: null }), (error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) }) ); for (let attempt = 0; attempt < 10 && gateCalls === 0; attempt += 1) { await Promise.resolve(); } const active = clone(scene.getDebugState().combatCutIn); const frame = active?.current?.textureKey ? scene.textures.getFrame(active.current.textureKey) : null; const textureFrameBounds = frame ? { x: 0, y: 0, width: frame.realWidth, height: frame.realHeight } : null; const preview = scene.combatPreview(attacker, target, 'attack'); const sceneBounds = { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height }; const restoreFixtureState = () => { scene.hideCombatCutIn(); scene.applyBattleSaveState(clone(originalSave)); scene.cameraTileX = originalCamera.x; scene.cameraTileY = originalCamera.y; scene.updateCameraView(); scene.phase = originalPhase; scene.selectedUnit = originalSelectedUnit; scene.pendingMove = originalPendingMove; scene.targetingAction = originalTargetingAction; scene.selectedUsable = originalSelectedUsable; scene.lockedTargetPreview = originalLockedTargetPreview; scene.bondChainJudgementLast = clone(originalJudgement); scene.combatCutInStageActive = clone(originalCutInActive); scene.combatCutInStageLast = clone(originalCutInLast); scene.battleSpeed = originalBattleSpeed; scene.fastForwardHeld = originalFastForwardHeld; scene.renderBattleSpeedControl(); scene.clearMarkers(); scene.hideCommandMenu(); window.history.replaceState({}, '', originalUrl); const restoredState = scene.getDebugState(); return { save: comparableSave(clone(scene.createBattleSaveState())), camera: { x: scene.cameraTileX, y: scene.cameraTileY }, phase: restoredState.phase, selectedUnitId: restoredState.selectedUnitId, pendingMove: clone(restoredState.pendingMove), selectedUsable: restoredState.selectedUsable, commandMenuVisible: restoredState.commandMenuVisible, markerCount: restoredState.markerCount, combatCutIn: clone(restoredState.combatCutIn) }; }; window.__RC_COMBAT_CUTIN_STAGE_FIXTURE__ = { scene, playback, releaseDelay, originalDelay, hadOwnDelay, restoreFixtureState }; return { ready: true, active, gateCalls, attackerId, targetId, previewTerrainLabel: preview.terrainLabel, sceneBounds, mapTextureKey: stateBefore.mapTextureKey, textureFrameBounds, expectedRestored }; }); assert( firstBattleCombatCutInStage.ready === true, `Expected a ready first-battle map-backed combat cut-in fixture: ${JSON.stringify(firstBattleCombatCutInStage)}` ); await page.screenshot({ path: `${screenshotDir}/rc-first-battle-combat-cutin-map-stage.png`, fullPage: true }); await assertCanvasPainted(page, 'first battle map-backed combat cut-in stage'); const firstBattleCombatCutInSettled = await page.evaluate(async () => { const fixture = window.__RC_COMBAT_CUTIN_STAGE_FIXTURE__; if (!fixture) { return { ready: false, reason: 'map-backed combat cut-in fixture handle was missing' }; } const { scene } = fixture; if (fixture.hadOwnDelay) { scene.delay = fixture.originalDelay; } else { delete scene.delay; } fixture.releaseDelay(); const playback = await Promise.race([ fixture.playback, new Promise((resolve) => window.setTimeout( () => resolve({ ok: false, error: 'map-backed combat cut-in playback timed out' }), 15000 )) ]); const settled = JSON.parse(JSON.stringify(scene.getDebugState().combatCutIn)); const restored = fixture.restoreFixtureState(); delete window.__RC_COMBAT_CUTIN_STAGE_FIXTURE__; return { ready: true, playback, settled, restored }; }); const activeCombatCutIn = firstBattleCombatCutInStage.active; const combatCutInStage = activeCombatCutIn?.current; const combatCutInCrop = combatCutInStage?.sourceCropPx; const combatCutInTexture = combatCutInStage?.textureBoundsPx; const combatCutInStageAspect = combatCutInStage?.stageBounds?.width / combatCutInStage?.stageBounds?.height; const combatCutInCropAspect = combatCutInCrop?.width / combatCutInCrop?.height; assert( activeCombatCutIn?.active === true && activeCombatCutIn.objectCount > 0 && firstBattleCombatCutInStage.gateCalls > 0 && combatCutInStage?.mode === 'attack' && combatCutInStage.mapBacked === true && combatCutInStage.textureKey === firstBattleCombatCutInStage.mapTextureKey, `Expected a gated real attack playback to expose one active map-backed cut-in stage: ${JSON.stringify(firstBattleCombatCutInStage)}` ); assert( isFiniteBounds(combatCutInTexture) && sameJsonValue(combatCutInTexture, firstBattleCombatCutInStage.textureFrameBounds) && isFiniteBounds(combatCutInCrop) && combatCutInCrop.x >= 0 && combatCutInCrop.y >= 0 && combatCutInCrop.x + combatCutInCrop.width <= combatCutInTexture.width + 0.01 && combatCutInCrop.y + combatCutInCrop.height <= combatCutInTexture.height + 0.01, `Expected the cut-in source crop to remain inside the loaded battle-map texture: ${JSON.stringify(combatCutInStage)}` ); assert( isFiniteBounds(combatCutInStage.panelBounds) && isFiniteBounds(combatCutInStage.stageBounds) && boundsInside(combatCutInStage.panelBounds, firstBattleCombatCutInStage.sceneBounds) && boundsInside(combatCutInStage.stageBounds, combatCutInStage.panelBounds) && Math.abs(combatCutInStageAspect - combatCutInCropAspect) < 0.001, `Expected panel, stage, and source crop to share a bounded HD composition without aspect distortion: ${JSON.stringify(combatCutInStage)}` ); assert( combatCutInStage.actor?.id === firstBattleCombatCutInStage.attackerId && combatCutInStage.actor.faction === 'ally' && combatCutInStage.actor.tile.x === 4 && combatCutInStage.actor.tile.y === 15 && typeof combatCutInStage.actor.terrain === 'string' && combatCutInStage.actor.terrain.length > 0 && combatCutInStage.target?.id === firstBattleCombatCutInStage.targetId && combatCutInStage.target.faction === 'enemy' && combatCutInStage.target.tile.x === 5 && combatCutInStage.target.tile.y === 15 && typeof combatCutInStage.target.terrain === 'string' && combatCutInStage.target.terrain.length > 0 && typeof firstBattleCombatCutInStage.previewTerrainLabel === 'string' && firstBattleCombatCutInStage.previewTerrainLabel.length > 0 && combatCutInStage.focalTile.x === 4.5 && combatCutInStage.focalTile.y === 15, `Expected the cut-in crop contract to retain actor, target, terrain, and midpoint focus semantics: ${JSON.stringify(firstBattleCombatCutInStage)}` ); assert( firstBattleCombatCutInSettled.ready === true && firstBattleCombatCutInSettled.playback?.ok === true && firstBattleCombatCutInSettled.settled?.active === false && firstBattleCombatCutInSettled.settled.objectCount === 0 && firstBattleCombatCutInSettled.settled.current === null && sameJsonValue(firstBattleCombatCutInSettled.settled.last, combatCutInStage), `Expected the real attack playback to finish cleanly while retaining its last map-stage snapshot: ${JSON.stringify(firstBattleCombatCutInSettled)}` ); assert( sameJsonValue(firstBattleCombatCutInSettled.restored, firstBattleCombatCutInStage.expectedRestored), `Expected the cut-in fixture to restore battle, camera, selection, marker, and prior debug state: ${JSON.stringify(firstBattleCombatCutInSettled)}` ); const firstBattleOrderHud = await assertLiveSortieOrderHud(page, { battleId: 'first-battle-zhuo-commandery', orderId: 'elite', context: 'first battle' }); assert( firstBattleOrderHud.hud?.commandReady === true && firstBattleOrderHud.hud.commandUnlocked === true && firstBattleOrderHud.hud.commandUnlockTurn === 1 && firstBattleOrderHud.hud.commandUsed === false && firstBattleOrderHud.hud.commandSource === 'sortie-order' && firstBattleOrderHud.hud.criteria.every((entry) => entry.id === 'victory' || entry.achieved === true) && firstBattleOrderHud.battleLog.some((entry) => entry.includes('군령 발동 가능')) && isFiniteBounds(firstBattleOrderHud.hud.commandBounds), `Expected the deterministic fixture to reach the real criteria/action unlock path and expose a ready one-use order command: ${JSON.stringify(firstBattleOrderHud)}` ); const firstBattleCommand = await activateSortieOrderCommand(page, 'support'); assert( firstBattleCommand.live?.commandReady === false && firstBattleCommand.live?.commandUnlocked === true && firstBattleCommand.live?.commandUsed === true && firstBattleCommand.live?.commandSource === 'sortie-order' && firstBattleCommand.live?.commandActorId === firstBattleCommand.live?.command?.actorId && firstBattleCommand.live?.command?.source === 'sortie-order' && firstBattleCommand.live?.command?.role === 'support' && firstBattleCommand.live?.command?.effectPending === false && firstBattleCommand.live?.command?.effectValue > 0, `Expected selecting 재정비 to consume the order command and resolve deterministic healing immediately: ${JSON.stringify(firstBattleCommand)}` ); 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 firstSortieOrder = firstResultState?.sortieOperationOrder; const firstSortieOrderResult = firstSortieOrder?.result; const firstSortieOrderCommand = firstSortieOrderResult?.command; assert( firstSortieOrder?.selectedId === 'elite' && firstSortieOrder?.open === false && firstSortieOrder?.toggleButtonBounds && firstSortieOrderResult?.orderId === 'elite' && firstSortieOrderResult?.progress?.map((entry) => entry.id).join(',') === 'victory,elite-grade,elite-survival', `Expected the scripted first battle to expose an explicit elite order result: ${JSON.stringify(firstSortieOrder)}` ); assert( firstSortieOrderCommand?.source === 'sortie-order' && firstSortieOrderCommand.role === 'support' && typeof firstSortieOrderCommand.actorId === 'string' && firstSortieOrderCommand.actorId.length > 0 && firstSortieOrderCommand.usedTurn === 1 && firstSortieOrderCommand.effectPending === false && firstSortieOrderCommand.effectValue > 0 && firstSortieOrderCommand.statusesCleared >= 0 && firstSortieOrderCommand.effectLost === false, `Expected the result snapshot to retain the resolved 재정비 order command: ${JSON.stringify(firstSortieOrderResult)}` ); assert( sameJsonValue(firstResultSave.current?.firstBattleReport?.sortieOrder, firstSortieOrderResult) && sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortieOrder, firstSortieOrderResult) && sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder, firstSortieOrderResult) && sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder, firstSortieOrderResult) && sameJsonValue(firstResultSave.current?.sortieOrderHistory?.['first-battle-zhuo-commandery']?.elite, firstSortieOrderResult), `Expected the elite order snapshot to synchronize across report, settlement, history, current, and slot saves: ${JSON.stringify(firstResultSave)}` ); assert( sameJsonValue(firstResultSave.current?.firstBattleReport?.sortieOrder?.command, firstSortieOrderCommand) && sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortieOrder?.command, firstSortieOrderCommand) && sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder?.command, firstSortieOrderCommand) && sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder?.command, firstSortieOrderCommand) && sameJsonValue(firstResultSave.current?.sortieOrderHistory?.['first-battle-zhuo-commandery']?.elite?.command, firstSortieOrderCommand) && sameJsonValue(firstResultSave.slot1?.sortieOrderHistory?.['first-battle-zhuo-commandery']?.elite?.command, firstSortieOrderCommand), `Expected the order-command snapshot to persist across current/slot reports, settlements, and order history: ${JSON.stringify(firstResultSave)}` ); if (firstSortieOrderResult.achieved) { assert( firstSortieOrderResult.rewardGranted === true && firstResultSave.current?.claimedSortieOrderRewardIds?.includes('first-battle-zhuo-commandery:elite') && firstResultSave.current?.inventory?.['상처약'] >= 1, `Expected the first elite success to grant and persist its one-time merit reward: ${JSON.stringify(firstResultSave.current)}` ); } 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 expectedFirstSortieReview = { version: 1, score: firstResultState.sortieEvaluation.score, grade: firstResultState.sortieEvaluation.grade, activeBondCount: firstResultState.sortieEvaluation.activeBondCount, enemyCount: firstResultState.units.filter((unit) => unit.faction === 'enemy').length, sourcePresetIds: [] }; assert( sameJsonValue(firstResultSave.current?.firstBattleReport?.sortieReview, expectedFirstSortieReview) && sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortieReview, expectedFirstSortieReview) && sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, expectedFirstSortieReview) && sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, expectedFirstSortieReview) && firstResultSave.current.firstBattleReport.sortieReview.sourcePresetIds.length === 0, `Expected the first persisted sortie review to match the live grade, score, enemies, bonds, and empty preset attribution everywhere: ${JSON.stringify({ expected: expectedFirstSortieReview, currentReport: firstResultSave.current?.firstBattleReport?.sortieReview, slotReport: firstResultSave.slot1?.firstBattleReport?.sortieReview, currentHistory: firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, slotHistory: firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview })}` ); const firstOrderTogglePoint = await readBattleSortieOrderControlPoint(page, 'toggle'); await page.mouse.click(firstOrderTogglePoint.x, firstOrderTogglePoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder?.open === true, undefined, { timeout: 30000 } ); const firstOrderDetail = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); return { state: window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder, texts: (scene?.resultSortieOrderObjects ?? []) .filter((object) => object?.type === 'Text') .map((object) => object.text) }; }); assert( firstOrderDetail.state?.closeButtonBounds && firstOrderDetail.texts.some((text) => text.includes('정예 작전 명령')) && firstOrderDetail.texts.some((text) => text.includes('전투 승리')) && firstOrderDetail.texts.some((text) => text.includes('A등급 이상')) && firstOrderDetail.texts.some((text) => text.includes('출전 전원 생존')) && firstOrderDetail.texts.some((text) => text.includes('군령 발동') && text.includes('재정비')) && firstOrderDetail.texts.some((text) => text.includes('1턴') && text.includes('회복')), `Expected the visible sortie-order detail to explain every elite condition and its used command: ${JSON.stringify(firstOrderDetail)}` ); await page.screenshot({ path: `${screenshotDir}/rc-first-battle-sortie-order.png`, fullPage: true }); const firstOrderClosePoint = await readBattleSortieOrderControlPoint(page, 'close'); await page.mouse.click(firstOrderClosePoint.x, firstOrderClosePoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder?.open === false, undefined, { timeout: 30000 } ); 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 clickLegacyUi(page, 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?.campRoster?.pageCount === 1 && firstCampProbe.state.campRoster.totalCount === 3 && firstCampProbe.state.campRoster.visibleUnitIds.length === 3 && firstCampProbe.state.campRoster.rowBounds.length === 3 && firstCampProbe.state.campRoster.rowBounds.every((row) => row.height >= 100) && firstCampProbe.state.campRoster.navigationBounds === null && firstCampProbe.state.campRoster.previousEnabled === false && firstCampProbe.state.campRoster.nextEnabled === false, `Expected the three-officer first camp to retain spacious cards without redundant pagination: ${JSON.stringify(firstCampProbe.state?.campRoster)}` ); 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('명령 기록') && text.includes('재정비')), `Expected the camp report to show the persisted command record: ${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 firstCampEvaluation = firstCampProbe.state?.reportFormationEvaluation; const firstCampHistory = firstCampProbe.state?.reportFormationHistory; const firstCampEvaluationSaveBefore = await readCampaignSave(page); assert( firstCampEvaluation?.available === true && firstCampEvaluation.open === false && firstCampEvaluation.battleId === 'first-battle-zhuo-commandery' && firstCampEvaluation.grade === firstResultState?.sortieEvaluation?.grade && firstCampEvaluation.score === firstResultState?.sortieEvaluation?.score && sameJsonValue(firstCampEvaluation.snapshot, firstResultPerformance) && firstCampEvaluation.toggleButtonBounds, `Expected the first camp report to expose the persisted battle formation evaluation: ${JSON.stringify(firstCampEvaluation)}` ); assert( firstCampHistory?.available === true && firstCampHistory.open === false && firstCampHistory.recordCount === 1 && firstCampHistory.page === 0 && firstCampHistory.pageCount === 1 && firstCampHistory.records?.length === 1 && firstCampHistory.records[0].battleId === 'first-battle-zhuo-commandery' && firstCampHistory.records[0].grade === expectedFirstSortieReview.grade && firstCampHistory.records[0].score === expectedFirstSortieReview.score && sameJsonValue(firstCampHistory.records[0].sourcePresetIds, expectedFirstSortieReview.sourcePresetIds) && firstCampHistory.selectedBattleId === 'first-battle-zhuo-commandery' && firstCampHistory.best?.battleId === 'first-battle-zhuo-commandery' && firstCampHistory.loadableBest?.battleId === 'first-battle-zhuo-commandery' && firstCampHistory.loadUndoAvailable === false && firstCampHistory.toggleButtonBounds, `Expected the first camp report to expose one closed, persisted formation-history record and its best loadable sortie: ${JSON.stringify(firstCampHistory)}` ); const firstCampOrder = firstCampProbe.state?.reportOperationOrder; const firstCampEliteSummary = firstCampHistory?.orderSummaries?.find((summary) => summary.orderId === 'elite'); assert( firstCampOrder?.available === true && firstCampOrder.orderId === 'elite' && firstCampOrder.achieved === firstSortieOrderResult.achieved && firstCampOrder.firstRewardGranted === firstSortieOrderResult.rewardGranted && sameSortieOrderCommand(firstCampOrder.command, firstSortieOrderCommand) && firstCampOrder.command?.label === '재정비' && firstCampOrder.command?.sourceLabel === '군령 발동' && firstCampOrder.command?.recordLine?.includes('회복') && firstCampHistory.records[0].sortieOrder?.orderId === 'elite' && firstCampHistory.records[0].sortieOrder?.achieved === firstSortieOrderResult.achieved && sameSortieOrderCommand(firstCampHistory.records[0].sortieOrder?.command, firstSortieOrderCommand) && firstCampHistory.selected?.sortieOrder?.progress?.length === 3 && sameSortieOrderCommand(firstCampHistory.selected?.sortieOrder?.command, firstSortieOrderCommand) && firstCampEliteSummary?.attempts === 1 && firstCampEliteSummary.successes === (firstSortieOrderResult.achieved ? 1 : 0) && firstCampEliteSummary.bestScore === firstSortieOrderResult.score, `Expected the camp report and formation ledger to surface the elite result and merit statistics: ${JSON.stringify({ firstCampOrder, firstCampHistory })}` ); await setDebugQueryParam(page, 'debugOrderCommandReady', null); if (firstSortieOrderResult.rewardGranted) { assert( firstCampOrder.rewardBadgeBounds && firstCampOrder.rewardLine.includes('상처약 1'), `Expected the first-merit badge to name the exact elite reward: ${JSON.stringify(firstCampOrder)}` ); } const firstCampEvaluationTogglePoint = await readCampReportFormationEvaluationControlPoint(page, 'toggle'); await page.mouse.click(firstCampEvaluationTogglePoint.x, firstCampEvaluationTogglePoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation?.open === true, undefined, { timeout: 30000 } ); const firstCampEvaluationProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); return { evaluation: window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation, texts: (scene?.reportFormationReviewObjects ?? []) .filter((object) => object?.type === 'Text') .map((object) => object.text) }; }); const firstCampEvaluationSaveOpen = await readCampaignSave(page); assert( firstCampEvaluationProbe.evaluation?.open === true && firstCampEvaluationProbe.texts.includes('최근 전투 편성 평가') && firstCampEvaluationProbe.texts.some((text) => text.includes('명단·역할만 저장') && text.includes('장비·보급 제외')) && firstCampEvaluationProbe.evaluation?.presetCards?.length === 3 && firstCampEvaluationProbe.evaluation.presetCards.every( (card) => card.saved === false && card.matching === false && card.actionButtonBounds ) && sameJsonValue(firstCampEvaluationSaveOpen, firstCampEvaluationSaveBefore), `Expected reopening the first camp formation evaluation to remain non-destructive and show three empty preset actions: ${JSON.stringify(firstCampEvaluationProbe)}` ); await page.screenshot({ path: `${screenshotDir}/rc-first-camp-formation-evaluation.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp formation evaluation'); const firstCampEvaluationClosePoint = await readCampReportFormationEvaluationControlPoint(page, 'close'); await page.mouse.click(firstCampEvaluationClosePoint.x, firstCampEvaluationClosePoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation?.open === false, undefined, { timeout: 30000 } ); const firstCampHistoryMutationFixture = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const campaign = scene?.campaign; const snapshot = campaign?.battleHistory?.['first-battle-zhuo-commandery']?.sortiePerformance; const targetId = snapshot?.selectedUnitIds?.find((unitId) => scene?.selectedSortieUnitIds?.includes(unitId)); const historicalRole = targetId ? snapshot?.formationAssignments?.[targetId] : undefined; const divergentRole = ['front', 'flank', 'support', 'reserve'].find((role) => role !== historicalRole); if ( !scene || !campaign || !snapshot?.selectedUnitIds?.length || !targetId || !historicalRole || !divergentRole || typeof scene.persistSortieSelection !== 'function' || typeof scene.render !== 'function' ) { return { ready: false, targetId: targetId ?? null, historicalRole: historicalRole ?? null, divergentRole: divergentRole ?? null }; } const before = { selectedUnitIds: [...scene.selectedSortieUnitIds], formationAssignments: { ...scene.sortieFormationAssignments }, itemAssignments: structuredClone(scene.sortieItemAssignments ?? {}), focusedUnitId: scene.sortieFocusedUnitId, planFeedback: scene.sortiePlanFeedback, rosterScroll: scene.sortieRosterScroll }; scene.sortieFormationAssignments = { ...scene.sortieFormationAssignments, [targetId]: divergentRole }; scene.persistSortieSelection(); scene.render(); return { ready: true, targetId, historicalRole, divergentRole, before, divergent: { selectedUnitIds: [...scene.selectedSortieUnitIds], formationAssignments: { ...scene.sortieFormationAssignments }, itemAssignments: structuredClone(scene.sortieItemAssignments ?? {}) } }; }); assert( firstCampHistoryMutationFixture.ready === true && firstCampHistoryMutationFixture.historicalRole !== firstCampHistoryMutationFixture.divergentRole && firstCampHistoryMutationFixture.divergent?.formationAssignments?.[firstCampHistoryMutationFixture.targetId] === firstCampHistoryMutationFixture.divergentRole, `Expected a deterministic current-formation mismatch before exercising best-history loading: ${JSON.stringify(firstCampHistoryMutationFixture)}` ); const firstCampHistorySaveBeforeOpen = await readCampaignSave(page); assert( sameJsonValue( firstCampHistorySaveBeforeOpen.current?.selectedSortieUnitIds, firstCampHistoryMutationFixture.divergent.selectedUnitIds ) && sameJsonValue( firstCampHistorySaveBeforeOpen.current?.sortieFormationAssignments, firstCampHistoryMutationFixture.divergent.formationAssignments ) && sameJsonValue( firstCampHistorySaveBeforeOpen.current?.sortieItemAssignments, firstCampHistoryMutationFixture.divergent.itemAssignments ) && sameJsonValue( firstCampHistorySaveBeforeOpen.slot1?.selectedSortieUnitIds, firstCampHistoryMutationFixture.divergent.selectedUnitIds ) && sameJsonValue( firstCampHistorySaveBeforeOpen.slot1?.sortieFormationAssignments, firstCampHistoryMutationFixture.divergent.formationAssignments ) && sameJsonValue( firstCampHistorySaveBeforeOpen.slot1?.sortieItemAssignments, firstCampHistoryMutationFixture.divergent.itemAssignments ), `Expected the deliberate history-load mismatch to synchronize across current and slot-1 saves: ${JSON.stringify(firstCampHistorySaveBeforeOpen)}` ); const firstCampHistoryTogglePoint = await readCampReportFormationHistoryControlPoint(page, 'toggle'); await page.mouse.click(firstCampHistoryTogglePoint.x, firstCampHistoryTogglePoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.open === true, undefined, { timeout: 30000 } ); const firstCampHistoryOpenProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); return { history: window.__HEROS_DEBUG__?.camp()?.reportFormationHistory, texts: (scene?.reportFormationHistoryObjects ?? []) .filter((object) => object?.type === 'Text') .map((object) => object.text) }; }); const firstCampHistorySaveOpen = await readCampaignSave(page); const firstCampHistoryLoadProjection = firstCampHistoryOpenProbe.history?.loadableBest; assert( firstCampHistoryOpenProbe.history?.open === true && firstCampHistoryOpenProbe.history.recordCount === 1 && firstCampHistoryOpenProbe.history.comparison?.matchesCurrent === false && firstCampHistoryOpenProbe.history.closeButtonBounds && firstCampHistoryOpenProbe.history.bestButtonBounds && firstCampHistoryOpenProbe.history.loadBestButtonBounds && firstCampHistoryOpenProbe.history.undoButtonBounds === null && sameJsonValue(firstCampHistoryLoadProjection?.projectedSelectedUnitIds, firstResultPerformance.selectedUnitIds) && firstResultPerformance.selectedUnitIds.every( (unitId) => firstCampHistoryLoadProjection?.projectedFormationAssignments?.[unitId] === firstResultPerformance.formationAssignments[unitId] ) && sameJsonValue( firstCampHistoryLoadProjection?.projectedItemAssignments, firstCampHistoryMutationFixture.divergent.itemAssignments ) && firstCampHistoryOpenProbe.texts.includes('편성 전적표') && firstCampHistoryOpenProbe.texts.includes('최고 편성') && firstCampHistoryOpenProbe.texts.includes('편성책 평균 · 군령 전적') && firstCampHistoryOpenProbe.texts.includes('현재 출전안 vs 최고 편성') && firstCampHistoryOpenProbe.texts.some((text) => text.includes('명령 기록') && text.includes('재정비') && text.includes('군령 발동')) && sameJsonValue(firstCampHistorySaveOpen, firstCampHistorySaveBeforeOpen), `Expected opening the first camp formation history to remain non-destructive and expose best/load controls: ${JSON.stringify(firstCampHistoryOpenProbe)}` ); await page.screenshot({ path: `${screenshotDir}/rc-first-camp-formation-history.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp formation history'); const firstCampHistoryBestPoint = await readCampReportFormationHistoryControlPoint(page, 'best'); await page.mouse.click(firstCampHistoryBestPoint.x, firstCampHistoryBestPoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.selectedBattleId === 'first-battle-zhuo-commandery', undefined, { timeout: 30000 } ); const firstCampHistorySaveAfterBest = await readCampaignSave(page); assert( sameJsonValue(firstCampHistorySaveAfterBest, firstCampHistorySaveBeforeOpen), `Expected selecting the best history card to leave campaign saves untouched: ${JSON.stringify(firstCampHistorySaveAfterBest)}` ); const firstCampHistoryLoadBestPoint = await readCampReportFormationHistoryControlPoint(page, 'loadBest'); await page.mouse.click(firstCampHistoryLoadBestPoint.x, firstCampHistoryLoadBestPoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.loadUndoAvailable === true, undefined, { timeout: 30000 } ); const firstCampHistoryAppliedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const firstCampHistoryAppliedSave = await readCampaignSave(page); assert( sameJsonValue(firstCampHistoryAppliedState?.selectedSortieUnitIds, firstCampHistoryLoadProjection.projectedSelectedUnitIds) && sameJsonValue( firstCampHistoryAppliedState?.sortieFormationAssignments, firstCampHistoryLoadProjection.projectedFormationAssignments ) && sameJsonValue( firstCampHistoryAppliedState?.sortieItemAssignments, firstCampHistoryLoadProjection.projectedItemAssignments ) && firstCampHistoryAppliedState?.reportFormationHistory?.comparison?.matchesCurrent === true && firstCampHistoryAppliedState?.reportFormationHistory?.loadUndoAvailable === true && firstCampHistoryAppliedState?.reportFormationHistory?.undoButtonBounds && firstCampHistoryAppliedState?.reportFormationHistory?.feedback?.includes('최고 편성 적용') && sameJsonValue( firstCampHistoryAppliedSave.current?.selectedSortieUnitIds, firstCampHistoryLoadProjection.projectedSelectedUnitIds ) && sameJsonValue( firstCampHistoryAppliedSave.slot1?.selectedSortieUnitIds, firstCampHistoryLoadProjection.projectedSelectedUnitIds ) && sameJsonValue( firstCampHistoryAppliedSave.current?.sortieFormationAssignments, firstCampHistoryAppliedState.sortieFormationAssignments ) && sameJsonValue( firstCampHistoryAppliedSave.slot1?.sortieFormationAssignments, firstCampHistoryAppliedState.sortieFormationAssignments ) && sameJsonValue( firstCampHistoryAppliedSave.current?.sortieItemAssignments, firstCampHistoryLoadProjection.projectedItemAssignments ) && sameJsonValue( firstCampHistoryAppliedSave.slot1?.sortieItemAssignments, firstCampHistoryLoadProjection.projectedItemAssignments ) && sameJsonValue(firstCampHistoryAppliedSave.current?.firstBattleReport?.sortieReview, expectedFirstSortieReview) && sameJsonValue( firstCampHistoryAppliedSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, expectedFirstSortieReview ) && sameJsonValue(firstCampHistoryAppliedSave.slot1?.firstBattleReport?.sortieReview, expectedFirstSortieReview) && sameJsonValue( firstCampHistoryAppliedSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, expectedFirstSortieReview ), `Expected one best-history click to apply persisted names and roles, preserve common supplies, and keep the stored review immutable: ${JSON.stringify({ state: firstCampHistoryAppliedState, save: firstCampHistoryAppliedSave })}` ); const firstCampHistoryUndoPoint = await readCampReportFormationHistoryControlPoint(page, 'undo'); await page.mouse.click(firstCampHistoryUndoPoint.x, firstCampHistoryUndoPoint.y); await page.waitForFunction( (fixture) => { const state = window.__HEROS_DEBUG__?.camp(); return state?.reportFormationHistory?.loadUndoAvailable === false && JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(fixture.divergent.selectedUnitIds) && JSON.stringify(state?.sortieFormationAssignments) === JSON.stringify(fixture.divergent.formationAssignments) && JSON.stringify(state?.sortieItemAssignments) === JSON.stringify(fixture.divergent.itemAssignments); }, firstCampHistoryMutationFixture, { timeout: 30000 } ); const firstCampHistoryUndoneState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const firstCampHistoryUndoneSave = await readCampaignSave(page); assert( firstCampHistoryUndoneState?.reportFormationHistory?.open === true && firstCampHistoryUndoneState.reportFormationHistory.loadUndoAvailable === false && firstCampHistoryUndoneState.reportFormationHistory.undoButtonBounds === null && firstCampHistoryUndoneState.reportFormationHistory.comparison?.matchesCurrent === false && firstCampHistoryUndoneState.reportFormationHistory.feedback?.includes('되돌리고') && sameJsonValue( firstCampHistoryUndoneSave.current?.selectedSortieUnitIds, firstCampHistoryMutationFixture.divergent.selectedUnitIds ) && sameJsonValue( firstCampHistoryUndoneSave.current?.sortieFormationAssignments, firstCampHistoryMutationFixture.divergent.formationAssignments ) && sameJsonValue( firstCampHistoryUndoneSave.current?.sortieItemAssignments, firstCampHistoryMutationFixture.divergent.itemAssignments ) && sameJsonValue( firstCampHistoryUndoneSave.slot1?.selectedSortieUnitIds, firstCampHistoryMutationFixture.divergent.selectedUnitIds ) && sameJsonValue( firstCampHistoryUndoneSave.slot1?.sortieFormationAssignments, firstCampHistoryMutationFixture.divergent.formationAssignments ) && sameJsonValue( firstCampHistoryUndoneSave.slot1?.sortieItemAssignments, firstCampHistoryMutationFixture.divergent.itemAssignments ) && sameJsonValue(firstCampHistoryUndoneSave.current?.firstBattleReport?.sortieReview, expectedFirstSortieReview) && sameJsonValue( firstCampHistoryUndoneSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, expectedFirstSortieReview ), `Expected the real history undo control to restore the exact prior names, roles, and supplies without touching the battle review: ${JSON.stringify({ state: firstCampHistoryUndoneState, save: firstCampHistoryUndoneSave })}` ); const firstCampHistoryClosePoint = await readCampReportFormationHistoryControlPoint(page, 'close'); await page.mouse.click(firstCampHistoryClosePoint.x, firstCampHistoryClosePoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.open === false, undefined, { timeout: 30000 } ); const firstCampHistoryClosedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( firstCampHistoryClosedState?.reportFormationHistory?.recordCount === 1 && firstCampHistoryClosedState.reportFormationHistory.open === false && firstCampHistoryClosedState.reportFormationHistory.closeButtonBounds === null && firstCampHistoryClosedState.reportFormationHistory.toggleButtonBounds, `Expected the real history close control to return to the first camp report: ${JSON.stringify(firstCampHistoryClosedState?.reportFormationHistory)}` ); await page.evaluate((before) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); if (!scene || typeof scene.persistSortieSelection !== 'function' || typeof scene.render !== 'function') { throw new Error('Expected CampScene runtime methods while restoring the first-camp history fixture.'); } scene.selectedSortieUnitIds = [...before.selectedUnitIds]; scene.sortieFormationAssignments = { ...before.formationAssignments }; scene.sortieItemAssignments = structuredClone(before.itemAssignments); scene.sortieFocusedUnitId = before.focusedUnitId; scene.sortiePlanFeedback = before.planFeedback; scene.sortieRosterScroll = before.rosterScroll; scene.persistSortieSelection(); scene.render(); }, firstCampHistoryMutationFixture.before); await page.waitForFunction( (before) => { const state = window.__HEROS_DEBUG__?.camp(); return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(before.selectedUnitIds) && JSON.stringify(state?.sortieFormationAssignments) === JSON.stringify(before.formationAssignments) && JSON.stringify(state?.sortieItemAssignments) === JSON.stringify(before.itemAssignments); }, firstCampHistoryMutationFixture.before, { timeout: 30000 } ); const firstCampHistoryRestoredSave = await readCampaignSave(page); assert( sameJsonValue( firstCampHistoryRestoredSave.current?.selectedSortieUnitIds, firstCampHistoryMutationFixture.before.selectedUnitIds ) && sameJsonValue( firstCampHistoryRestoredSave.current?.sortieFormationAssignments, firstCampHistoryMutationFixture.before.formationAssignments ) && sameJsonValue( firstCampHistoryRestoredSave.current?.sortieItemAssignments, firstCampHistoryMutationFixture.before.itemAssignments ) && sameJsonValue( firstCampHistoryRestoredSave.slot1?.selectedSortieUnitIds, firstCampHistoryMutationFixture.before.selectedUnitIds ) && sameJsonValue( firstCampHistoryRestoredSave.slot1?.sortieFormationAssignments, firstCampHistoryMutationFixture.before.formationAssignments ) && sameJsonValue( firstCampHistoryRestoredSave.slot1?.sortieItemAssignments, firstCampHistoryMutationFixture.before.itemAssignments ), `Expected the RC-only mismatch fixture to restore the original first-camp sortie before continuing: ${JSON.stringify(firstCampHistoryRestoredSave)}` ); const firstCampSupplyMutationFixture = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const state = window.__HEROS_DEBUG__?.camp(); const campaign = scene?.campaign; const report = scene?.report; const targetId = state?.reportFormationEvaluation?.snapshot?.selectedUnitIds?.find((unitId) => campaign?.roster?.some((unit) => unit.id === unitId) ); const target = campaign?.roster?.find((unit) => unit.id === targetId); const reportUnit = report?.units?.find((unit) => unit.id === targetId && unit.faction === 'ally'); const settlementUnit = campaign?.battleHistory?.[report?.battleId]?.units?.find((unit) => unit.unitId === targetId); if (!scene || !campaign || !report || !target || !reportUnit || !settlementUnit || typeof scene.render !== 'function') { return { ready: false, targetId: targetId ?? null }; } const beanBefore = campaign.inventory?.['콩'] ?? 0; target.hp = Math.max(1, target.maxHp - 12); reportUnit.hp = target.hp; campaign.inventory['콩'] = beanBefore + 1; scene.selectedUnitId = target.id; scene.render(); return { ready: true, targetId: target.id, woundedHp: target.hp, maxHp: target.maxHp, settlementHp: settlementUnit.hp, beanBefore }; }); assert( firstCampSupplyMutationFixture.ready === true && firstCampSupplyMutationFixture.targetId && firstCampSupplyMutationFixture.woundedHp < firstCampSupplyMutationFixture.maxHp, `Expected a deterministic wounded report-unit fixture for camp supply recovery: ${JSON.stringify(firstCampSupplyMutationFixture)}` ); await clickLegacyUi(page, 798, 38); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.activeTab === 'supplies', undefined, { timeout: 30000 } ); const firstCampUseSupplyPoint = await readCampSupplyUseControlPoint(page, '콩'); await page.mouse.click(firstCampUseSupplyPoint.x, firstCampUseSupplyPoint.y); await page.waitForTimeout(120); const firstCampSupplyClickApplied = await page.evaluate((fixture) => { const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === fixture.targetId); return target?.hp === fixture.maxHp; }, firstCampSupplyMutationFixture); if (!firstCampSupplyClickApplied) { const fallbackApplied = await page.evaluate(({ fixture, supplyLabel }) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const texts = (scene?.contentObjects ?? []).filter((object) => object?.type === 'Text' && object?.active && object?.visible); const supplyTitle = texts.find((object) => object.text?.startsWith(`${supplyLabel} x`)); const useText = texts .filter((object) => object.text === '사용') .sort((left, right) => Math.abs(left.y - (supplyTitle?.y ?? 0)) - Math.abs(right.y - (supplyTitle?.y ?? 0)))[0]; const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === fixture.targetId); if (!scene || !supplyTitle || !useText || !target) { return { ready: false, supplyTitle: supplyTitle?.text ?? null, useFound: Boolean(useText), targetHp: target?.hp ?? null }; } useText.emit('pointerdown'); return { ready: true, supplyTitle: supplyTitle.text, targetHp: target.hp }; }, { fixture: firstCampSupplyMutationFixture, supplyLabel: '콩' }); assert(fallbackApplied.ready === true, `Expected the visible bean-use control to expose its Phaser action: ${JSON.stringify(fallbackApplied)}`); } await page.waitForFunction((fixture) => { const state = window.__HEROS_DEBUG__?.camp(); const target = state?.campaign?.roster?.find((unit) => unit.id === fixture.targetId); return target?.hp === fixture.maxHp; }, firstCampSupplyMutationFixture, { timeout: 30000 }); const firstCampAfterSupplyProbe = await page.evaluate((fixture) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const state = window.__HEROS_DEBUG__?.camp(); const report = scene?.report; const settlement = scene?.campaign?.battleHistory?.[report?.battleId]; return { evaluation: state?.reportFormationEvaluation, rosterHp: state?.campaign?.roster?.find((unit) => unit.id === fixture.targetId)?.hp ?? null, reportHp: report?.units?.find((unit) => unit.id === fixture.targetId && unit.faction === 'ally')?.hp ?? null, settlementHp: settlement?.units?.find((unit) => unit.unitId === fixture.targetId)?.hp ?? null, beanAmount: state?.campaign?.inventory?.['콩'] ?? 0 }; }, firstCampSupplyMutationFixture); assert( firstCampAfterSupplyProbe.rosterHp === firstCampSupplyMutationFixture.maxHp && firstCampAfterSupplyProbe.reportHp === firstCampSupplyMutationFixture.maxHp && firstCampAfterSupplyProbe.settlementHp === firstCampSupplyMutationFixture.settlementHp && firstCampAfterSupplyProbe.beanAmount === firstCampSupplyMutationFixture.beanBefore && firstCampAfterSupplyProbe.evaluation?.grade === firstCampEvaluation.grade && firstCampAfterSupplyProbe.evaluation?.score === firstCampEvaluation.score && firstCampAfterSupplyProbe.evaluation?.survivorCount === firstCampEvaluation.survivorCount && firstCampAfterSupplyProbe.evaluation?.recoveryNeededCount === firstCampEvaluation.recoveryNeededCount && sameJsonValue(firstCampAfterSupplyProbe.evaluation?.snapshot, firstCampEvaluation.snapshot), `Expected camp supply use to update report HP without rewriting the settlement-backed formation evaluation: ${JSON.stringify({ before: firstCampEvaluation, fixture: firstCampSupplyMutationFixture, after: firstCampAfterSupplyProbe })}` ); const firstCampSelectedSortieUnitIds = firstCampProbe.state?.selectedSortieUnitIds ?? []; const firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? []; await clickLegacyUi(page, 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)}` ); const expectedFirstSortiePursuitPairs = [ { id: 'liu-bei__guan-yu', unitIds: ['liu-bei', 'guan-yu'], unitNames: ['유비', '관우'], title: '도원결의', level: 72, chainRate: 18, baseChainRate: 18, core: false, damageBonus: 9 }, { id: 'liu-bei__zhang-fei', unitIds: ['liu-bei', 'zhang-fei'], unitNames: ['유비', '장비'], title: '의형제', level: 68, chainRate: 8, baseChainRate: 8, core: false, damageBonus: 9 }, { id: 'guan-yu__zhang-fei', unitIds: ['guan-yu', 'zhang-fei'], unitNames: ['관우', '장비'], title: '맹장 공명', level: 64, chainRate: 8, baseChainRate: 8, core: false, damageBonus: 9 } ]; const firstSortiePursuit = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); const logicalViewportBounds = { x: 0, y: 0, width: 1920, height: 1080 }; assert( sameJsonValue(firstSortiePursuit?.activePairs, expectedFirstSortiePursuitPairs) && firstSortiePursuit?.selectableOpportunities?.length === 0 && firstSortiePursuit?.selectedCoreBondId === null && firstSortiePursuit?.selectionBattleId === null && sameJsonValue( firstSortiePursuit?.coreCandidates?.map((candidate) => ({ id: candidate.id, level: candidate.level, baseChainRate: candidate.baseChainRate, coreChainRate: candidate.coreChainRate, selected: candidate.selected })), [ { id: 'liu-bei__guan-yu', level: 72, baseChainRate: 18, coreChainRate: 28, selected: false }, { id: 'liu-bei__zhang-fei', level: 68, baseChainRate: 8, coreChainRate: 18, selected: false }, { id: 'guan-yu__zhang-fei', level: 64, baseChainRate: 8, coreChainRate: 18, selected: false } ] ), `Expected the selected founding trio to expose ordered 18/8/8 resonance-pursuit pairs without reserve opportunities: ${JSON.stringify(firstSortiePursuit)}` ); assert( firstSortiePursuit?.summaryText === '핵심 공명조 · 미지정 · 후보 3조' && firstSortiePursuit?.ruleText === 'Lv30+ 한 조 · 핵심 추격 8/18/28% · 재클릭 해제' && isFiniteBounds(firstSortiePursuit.panelBounds) && isFiniteBounds(firstSortiePursuit.ruleBounds) && firstSortiePursuit.page === 0 && firstSortiePursuit.pageCount === 1 && firstSortiePursuit.pageText === '1/1' && firstSortiePursuit.prevEnabled === false && firstSortiePursuit.nextEnabled === false && isFiniteBounds(firstSortiePursuit.prevBounds) && isFiniteBounds(firstSortiePursuit.nextBounds) && firstSortiePursuit.prevClickBounds === null && firstSortiePursuit.nextClickBounds === null && boundsInside(firstSortiePursuit.prevBounds, firstSortiePursuit.panelBounds) && boundsInside(firstSortiePursuit.nextBounds, firstSortiePursuit.panelBounds) && boundsInside(firstSortiePursuit.panelBounds, logicalViewportBounds) && boundsInside(firstSortiePursuit.ruleBounds, firstSortiePursuit.panelBounds) && sameJsonValue(firstSortiePursuit.rows?.map((row) => ({ bondId: row.bondId, state: row.state, selected: row.selected, chainRate: row.chainRate, text: row.text })), [ { bondId: 'liu-bei__guan-yu', state: 'candidate', selected: false, chainRate: 28, text: '지정 28% 유비↔관우' }, { bondId: 'liu-bei__zhang-fei', state: 'candidate', selected: false, chainRate: 18, text: '지정 18% 유비↔장비' }, { bondId: 'guan-yu__zhang-fei', state: 'candidate', selected: false, chainRate: 18, text: '지정 18% 관우↔장비' } ]) && firstSortiePursuit.rows.every( (row) => isFiniteBounds(row.bounds) && isFiniteBounds(row.chipBounds) && isFiniteBounds(row.textBounds) && sameJsonValue(row.bounds, row.chipBounds) && boundsInside(row.bounds, firstSortiePursuit.panelBounds) && boundsInside(row.textBounds, row.bounds, 2) ), `Expected all three interactive core-resonance chips and their labels to remain visible inside the first-sortie panel: ${JSON.stringify(firstSortiePursuit)}` ); const coreResonanceToggleTarget = firstSortiePursuit.rows.find( (row) => row.bondId === 'liu-bei__zhang-fei' ); assert( isFiniteBounds(coreResonanceToggleTarget?.chipBounds), `Expected the lower-level brother pair to expose an interactive core-resonance chip: ${JSON.stringify(coreResonanceToggleTarget)}` ); await page.mouse.click( coreResonanceToggleTarget.chipBounds.x + coreResonanceToggleTarget.chipBounds.width / 2, coreResonanceToggleTarget.chipBounds.y + coreResonanceToggleTarget.chipBounds.height / 2 ); await page.waitForFunction(() => { const pursuit = window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview; return pursuit?.selectedCoreBondId === 'liu-bei__zhang-fei' && pursuit?.rows?.some((row) => row.bondId === 'liu-bei__zhang-fei' && row.selected === true); }, undefined, { timeout: 30000 }); const selectedCoreResonance = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); const selectedCoreResonanceSave = await readCampaignSave(page); await page.screenshot({ path: `${screenshotDir}/rc-first-camp-core-resonance-selected.png`, fullPage: true }); assert( selectedCoreResonance?.selectionBattleId === 'second-battle-yellow-turban-pursuit' && selectedCoreResonance?.selectedCoreBondId === 'liu-bei__zhang-fei' && selectedCoreResonance?.summaryText === '핵심 공명조 · 지정 · 후보 3조' && selectedCoreResonance?.activePairs?.[0]?.id === 'liu-bei__zhang-fei' && selectedCoreResonance.activePairs[0].core === true && selectedCoreResonance.activePairs[0].baseChainRate === 8 && selectedCoreResonance.activePairs[0].chainRate === 18 && selectedCoreResonance?.coreCandidates?.[0]?.id === 'liu-bei__zhang-fei' && selectedCoreResonance.coreCandidates[0].selected === true && selectedCoreResonance?.rows?.[0]?.bondId === 'liu-bei__zhang-fei' && selectedCoreResonance.rows[0].state === 'selected' && selectedCoreResonance.rows[0].text === '핵심 18% 유비↔장비' && selectedCoreResonanceSave.current?.sortieResonanceSelection?.battleId === 'second-battle-yellow-turban-pursuit' && selectedCoreResonanceSave.current.sortieResonanceSelection.bondId === 'liu-bei__zhang-fei', `Expected one clicked pair to become the persisted, prioritized 18% core resonance: ${JSON.stringify({ selectedCoreResonance, save: selectedCoreResonanceSave.current?.sortieResonanceSelection })}` ); const coreResonanceAutoClearFixture = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); if (!scene || typeof scene.toggleSortieUnit !== 'function') { return { ready: false }; } const before = { selectedUnitIds: [...scene.selectedSortieUnitIds], formationAssignments: { ...scene.sortieFormationAssignments }, itemAssignments: structuredClone(scene.sortieItemAssignments ?? {}), focusedUnitId: scene.sortieFocusedUnitId ?? null, planFeedback: scene.sortiePlanFeedback, rosterScroll: scene.sortieRosterScroll }; scene.toggleSortieUnit('zhang-fei'); return { ready: true, before }; }); await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortiePursuitPreview?.selectedCoreBondId === null && !state?.selectedSortieUnitIds?.includes('zhang-fei'); }, undefined, { timeout: 30000 } ); const clearedCoreResonance = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); const clearedCoreCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const clearedCoreResonanceSave = await readCampaignSave(page); assert( coreResonanceAutoClearFixture.ready === true && clearedCoreResonance?.selectedCoreBondId === null && clearedCoreResonance?.activePairs?.every((pair) => pair.core === false) && clearedCoreResonance?.rows?.every((row) => row.selected === false && row.state === 'candidate') && !clearedCoreCampState?.selectedSortieUnitIds?.includes('zhang-fei') && clearedCoreCampState?.sortiePlanFeedback?.includes('핵심 공명조 자동 해제') && clearedCoreResonanceSave.current?.sortieResonanceSelection === undefined, `Expected removing a designated member to clear the persisted core pair and retain ordinary pursuit behavior: ${JSON.stringify({ clearedCoreCampState, clearedCoreResonance, save: clearedCoreResonanceSave.current?.sortieResonanceSelection })}` ); await page.evaluate((before) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); scene.selectedSortieUnitIds = [...before.selectedUnitIds]; scene.sortieFormationAssignments = { ...before.formationAssignments }; scene.sortieItemAssignments = structuredClone(before.itemAssignments); scene.sortieFocusedUnitId = before.focusedUnitId ?? undefined; scene.sortiePlanFeedback = before.planFeedback; scene.sortieRosterScroll = before.rosterScroll; scene.persistSortieSelection(); scene.showSortiePrep(); }, coreResonanceAutoClearFixture.before); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.selectedSortieUnitIds?.includes('zhang-fei') === true, undefined, { timeout: 30000 } ); const firstSortiePursuitSaveBefore = await readCampaignSave(page); const firstSortiePursuitMutation = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); if ( !scene || typeof scene.persistSortieSelection !== 'function' || typeof scene.showSortiePrep !== 'function' ) { return { ready: false }; } const before = { selectedUnitIds: [...scene.selectedSortieUnitIds], formationAssignments: { ...scene.sortieFormationAssignments }, itemAssignments: structuredClone(scene.sortieItemAssignments ?? {}), focusedUnitId: scene.sortieFocusedUnitId ?? null, planFeedback: scene.sortiePlanFeedback, rosterScroll: scene.sortieRosterScroll }; scene.selectedSortieUnitIds = scene.selectedSortieUnitIds.filter((unitId) => unitId !== 'guan-yu'); const formationAssignments = { ...scene.sortieFormationAssignments }; delete formationAssignments['guan-yu']; scene.sortieFormationAssignments = formationAssignments; const itemAssignments = structuredClone(scene.sortieItemAssignments ?? {}); delete itemAssignments['guan-yu']; scene.sortieItemAssignments = itemAssignments; scene.sortieFocusedUnitId = 'guan-yu'; const state = window.__HEROS_DEBUG__?.camp(); return { ready: true, before, after: { selectedUnitIds: state?.selectedSortieUnitIds, formationAssignments: state?.sortieFormationAssignments, itemAssignments: state?.sortieItemAssignments, focusedUnitId: state?.sortieFocusedUnitId, pursuit: state?.sortiePursuitPreview, focusedSynergy: state?.sortieFocusedSynergyPreview } }; }); const expectedGuanYuPursuitOpportunities = [ { ...expectedFirstSortiePursuitPairs[0], selectedPartnerId: 'liu-bei', selectedPartnerName: '유비', candidateUnitId: 'guan-yu', candidateUnitName: '관우' }, { ...expectedFirstSortiePursuitPairs[2], selectedPartnerId: 'zhang-fei', selectedPartnerName: '장비', candidateUnitId: 'guan-yu', candidateUnitName: '관우' } ]; assert( firstSortiePursuitMutation.ready === true && !firstSortiePursuitMutation.after?.selectedUnitIds?.includes('guan-yu') && firstSortiePursuitMutation.after?.focusedUnitId === 'guan-yu' && !Object.prototype.hasOwnProperty.call(firstSortiePursuitMutation.after?.formationAssignments ?? {}, 'guan-yu') && !Object.prototype.hasOwnProperty.call(firstSortiePursuitMutation.after?.itemAssignments ?? {}, 'guan-yu'), `Expected the RC fixture to bench Guan Yu and clear only his live role and supply assignments: ${JSON.stringify(firstSortiePursuitMutation)}` ); assert( sameJsonValue(firstSortiePursuitMutation.after?.pursuit?.activePairs, [expectedFirstSortiePursuitPairs[1]]) && sameJsonValue( firstSortiePursuitMutation.after?.pursuit?.selectableOpportunities, expectedGuanYuPursuitOpportunities ), `Expected benching Guan Yu to leave one active 8% pursuit and expose his 18%/8% partner opportunities: ${JSON.stringify(firstSortiePursuitMutation.after?.pursuit)}` ); assert( firstSortiePursuitMutation.after?.focusedSynergy?.mode === 'add' && firstSortiePursuitMutation.after.focusedSynergy.comparison?.activeBondDelta === 2 && sameJsonValue( firstSortiePursuitMutation.after.focusedSynergy.comparison.gainedBonds?.map((bond) => bond.id), ['liu-bei__guan-yu', 'guan-yu__zhang-fei'] ) && firstSortiePursuitMutation.after.focusedSynergy.comparison.lostBonds?.length === 0 && sameJsonValue( firstSortiePursuitMutation.after.focusedSynergy.projected?.activeBonds?.map((bond) => bond.id), expectedFirstSortiePursuitPairs.map((pair) => pair.id) ), `Expected Guan Yu's focused add projection to restore both pursuit bonds and the full trio: ${JSON.stringify(firstSortiePursuitMutation.after?.focusedSynergy)}` ); const firstSortiePursuitRemoveProjection = await page.evaluate((before) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); if (!scene || typeof scene.persistSortieSelection !== 'function' || typeof scene.showSortiePrep !== 'function') { return { ready: false }; } const scenario = scene.nextSortieScenario(); const current = scene.sortieSynergySnapshot(before.selectedUnitIds, scenario, before.formationAssignments); const projectedUnitIds = before.selectedUnitIds.filter((unitId) => unitId !== 'guan-yu'); const projectedFormationAssignments = { ...before.formationAssignments }; delete projectedFormationAssignments['guan-yu']; const projected = scene.sortieSynergySnapshot(projectedUnitIds, scenario, projectedFormationAssignments); const pursuitChanges = scene.changedSortiePursuitPairs(current, projected); scene.selectedSortieUnitIds = [...before.selectedUnitIds]; scene.sortieFormationAssignments = { ...before.formationAssignments }; scene.sortieItemAssignments = structuredClone(before.itemAssignments); scene.sortieFocusedUnitId = before.focusedUnitId ?? undefined; scene.sortiePlanFeedback = before.planFeedback; scene.sortieRosterScroll = before.rosterScroll; scene.persistSortieSelection(); scene.showSortiePrep(); const state = window.__HEROS_DEBUG__?.camp(); return { ready: true, selectedUnitIds: state?.selectedSortieUnitIds, pursuit: state?.sortiePursuitPreview, removeProjection: { currentPairIds: pursuitChanges.currentPairs.map((pair) => pair.id), projectedPairIds: pursuitChanges.projectedPairs.map((pair) => pair.id), gainedPairIds: pursuitChanges.gainedPairs.map((pair) => pair.id), lostPairIds: pursuitChanges.lostPairs.map((pair) => pair.id), activePairDelta: pursuitChanges.projectedPairs.length - pursuitChanges.currentPairs.length } }; }, firstSortiePursuitMutation.before); assert( firstSortiePursuitRemoveProjection.ready === true && sameJsonValue(firstSortiePursuitRemoveProjection.selectedUnitIds, firstSortiePursuitMutation.before?.selectedUnitIds) && sameJsonValue(firstSortiePursuitRemoveProjection.pursuit?.activePairs, expectedFirstSortiePursuitPairs) && firstSortiePursuitRemoveProjection.pursuit?.selectableOpportunities?.length === 0 && sameJsonValue( firstSortiePursuitRemoveProjection.removeProjection?.currentPairIds, expectedFirstSortiePursuitPairs.map((pair) => pair.id) ) && sameJsonValue(firstSortiePursuitRemoveProjection.removeProjection?.projectedPairIds, ['liu-bei__zhang-fei']) && firstSortiePursuitRemoveProjection.removeProjection?.activePairDelta === -2 && sameJsonValue( firstSortiePursuitRemoveProjection.removeProjection?.lostPairIds, ['liu-bei__guan-yu', 'guan-yu__zhang-fei'] ) && firstSortiePursuitRemoveProjection.removeProjection?.gainedPairIds?.length === 0, `Expected the restored trio to preview exactly the two pursuit pairs lost by removing Guan Yu: ${JSON.stringify(firstSortiePursuitRemoveProjection)}` ); const firstSortiePursuitRestored = await page.evaluate((before) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); if (!scene || typeof scene.persistSortieSelection !== 'function' || typeof scene.showSortiePrep !== 'function') { return { ready: false }; } scene.selectedSortieUnitIds = [...before.selectedUnitIds]; scene.sortieFormationAssignments = { ...before.formationAssignments }; scene.sortieItemAssignments = structuredClone(before.itemAssignments); scene.sortieFocusedUnitId = before.focusedUnitId ?? undefined; scene.sortiePlanFeedback = before.planFeedback; scene.sortieRosterScroll = before.rosterScroll; scene.persistSortieSelection(); scene.showSortiePrep(); const state = window.__HEROS_DEBUG__?.camp(); return { ready: true, selectedUnitIds: state?.selectedSortieUnitIds, formationAssignments: state?.sortieFormationAssignments, itemAssignments: state?.sortieItemAssignments, focusedUnitId: state?.sortieFocusedUnitId ?? null, planFeedback: state?.sortiePlanFeedback, rosterScroll: scene.sortieRosterScroll, deploymentPreview: state?.sortieDeploymentPreview, pursuit: state?.sortiePursuitPreview }; }, firstSortiePursuitMutation.before); const firstSortiePursuitSaveRestored = await readCampaignSave(page); assert( firstSortiePursuitRestored.ready === true && sameJsonValue(firstSortiePursuitRestored.selectedUnitIds, firstSortiePursuitMutation.before?.selectedUnitIds) && sameJsonValue(firstSortiePursuitRestored.formationAssignments, firstSortiePursuitMutation.before?.formationAssignments) && sameJsonValue(firstSortiePursuitRestored.itemAssignments, firstSortiePursuitMutation.before?.itemAssignments) && firstSortiePursuitRestored.focusedUnitId === firstSortiePursuitMutation.before?.focusedUnitId && firstSortiePursuitRestored.planFeedback === firstSortiePursuitMutation.before?.planFeedback && firstSortiePursuitRestored.rosterScroll === firstSortiePursuitMutation.before?.rosterScroll && sameJsonValue(firstSortiePursuitRestored.deploymentPreview, firstCampDeploymentPreview) && sameJsonValue(firstSortiePursuitRestored.pursuit?.activePairs, expectedFirstSortiePursuitPairs), `Expected the pursuit RC fixture to restore the exact live formation, roles, supplies, focus, feedback, scroll, and deployment: ${JSON.stringify(firstSortiePursuitRestored)}` ); assert( sameJsonValue( firstSortiePursuitSaveRestored.current?.selectedSortieUnitIds, firstSortiePursuitSaveBefore.current?.selectedSortieUnitIds ) && sameJsonValue( firstSortiePursuitSaveRestored.current?.sortieFormationAssignments, firstSortiePursuitSaveBefore.current?.sortieFormationAssignments ) && sameJsonValue( firstSortiePursuitSaveRestored.current?.sortieItemAssignments, firstSortiePursuitSaveBefore.current?.sortieItemAssignments ) && sameJsonValue( firstSortiePursuitSaveRestored.slot1?.selectedSortieUnitIds, firstSortiePursuitSaveBefore.slot1?.selectedSortieUnitIds ) && sameJsonValue( firstSortiePursuitSaveRestored.slot1?.sortieFormationAssignments, firstSortiePursuitSaveBefore.slot1?.sortieFormationAssignments ) && sameJsonValue( firstSortiePursuitSaveRestored.slot1?.sortieItemAssignments, firstSortiePursuitSaveBefore.slot1?.sortieItemAssignments ), `Expected the pursuit RC fixture to restore current and slot-1 sortie saves before launch: ${JSON.stringify(firstSortiePursuitSaveRestored)}` ); const launchCoreResonanceRow = firstSortiePursuitRestored.pursuit?.rows?.find( (row) => row.bondId === 'liu-bei__zhang-fei' ); assert( isFiniteBounds(launchCoreResonanceRow?.chipBounds), `Expected the restored formation to keep the Liu Bei/Zhang Fei core-resonance control available: ${JSON.stringify(firstSortiePursuitRestored.pursuit)}` ); await page.mouse.click( launchCoreResonanceRow.chipBounds.x + launchCoreResonanceRow.chipBounds.width / 2, launchCoreResonanceRow.chipBounds.y + launchCoreResonanceRow.chipBounds.height / 2 ); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.selectedCoreBondId === 'liu-bei__zhang-fei', undefined, { timeout: 30000 } ); const launchCoreResonance = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); assert( launchCoreResonance?.activePairs?.[0]?.id === 'liu-bei__zhang-fei' && launchCoreResonance.activePairs[0].core === true && launchCoreResonance.activePairs[0].chainRate === 18, `Expected the lower legacy-rate pair to be promoted and prioritized before battle launch: ${JSON.stringify(launchCoreResonance)}` ); await advanceSortiePrepStep(page, 'loadout'); await clickLegacyUi(page, 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)}`); assert( secondBattleProbe?.sortieOperationOrder?.selectedId === 'elite' && secondBattleProbe.sortieOperationOrder.result === null, `Expected the explicitly declared elite order to reach the next battle unchanged: ${JSON.stringify(secondBattleProbe?.sortieOperationOrder)}` ); await assertLiveSortieOrderHud(page, { battleId: 'second-battle-yellow-turban-pursuit', orderId: 'elite', context: 'second battle' }); 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.coreSortieResonance?.selected === true && secondBattleProbe.coreSortieResonance.selectedBondId === 'liu-bei__zhang-fei' && secondBattleProbe.coreSortieResonance.valid === true && secondBattleProbe.coreSortieResonance.active === true && secondBattleProbe.coreSortieResonance.level === 68 && secondBattleProbe.coreSortieResonance.rate === 18 && sameJsonValue(secondBattleProbe.coreSortieResonance.unitIds, ['liu-bei', 'zhang-fei']) && sameJsonValue(secondBattleProbe.coreSortieResonance.stats, { attempts: 0, successes: 0, failures: 0, additionalDamage: 0 }) && sameJsonValue(secondBattleProbe.sortieResonance, secondBattleProbe.coreSortieResonance), `Expected the camp designation to reach battle as one active 18% core resonance with clean statistics: ${JSON.stringify(secondBattleProbe.coreSortieResonance)}` ); const secondBattleCorePriority = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const attacker = scene?.debugUnitById('liu-bei'); const corePartner = scene?.debugUnitById('zhang-fei'); const ordinaryPartner = scene?.debugUnitById('guan-yu'); const targetState = window.__HEROS_DEBUG__?.battle()?.units?.find((unit) => unit.faction === 'enemy' && unit.hp > 0); const target = targetState ? scene?.debugUnitById(targetState.id) : undefined; if (!scene || !attacker || !corePartner || !ordinaryPartner || !target) { return { ready: false }; } const touchedUnits = [attacker, corePartner, ordinaryPartner, target]; const original = { units: touchedUnits.map((unit) => ({ unit, x: unit.x, y: unit.y })), attackIntents: scene.attackIntents.map((intent) => ({ ...intent })), actedUnitIds: new Set(scene.actedUnitIds), targetHp: target.hp, battleStats: new Map( [...scene.battleStats.entries()].map(([unitId, stats]) => [unitId, structuredClone(stats)]) ) }; try { Object.assign(attacker, { x: 5, y: 5 }); Object.assign(corePartner, { x: 5, y: 6 }); Object.assign(ordinaryPartner, { x: 4, y: 5 }); Object.assign(target, { x: 6, y: 5 }); scene.attackIntents = [ { attackerId: corePartner.id, targetId: target.id }, { attackerId: ordinaryPartner.id, targetId: target.id } ]; scene.actedUnitIds = new Set([...original.actedUnitIds, corePartner.id, ordinaryPartner.id]); const candidate = scene.activeBondChainCandidate(attacker, target, 'attack'); const preview = scene.combatPreview(attacker, target, 'attack'); const failedResolution = scene.resolveBondChainFollowUp(preview, true, false); const successfulResolution = scene.resolveBondChainFollowUp(preview, true, true); const coreStats = window.__HEROS_DEBUG__?.battle()?.coreSortieResonance?.stats ?? null; return { ready: true, candidate: candidate ? { bondId: candidate.bondId, partnerId: candidate.partner.id, rate: candidate.rate, coreResonance: candidate.coreResonance, label: candidate.label } : null, preview: { bondId: preview.bondChainBondId ?? null, partnerId: preview.bondChainPartnerId ?? null, rate: preview.bondChainRate, coreResonance: preview.bondChainCoreResonance, label: preview.bondChainLabel ?? null }, failedResolution: failedResolution ? { succeeded: failedResolution.attempt.succeeded, coreResonance: failedResolution.attempt.coreResonance, hasResult: Boolean(failedResolution.result) } : null, successfulResolution: successfulResolution ? { succeeded: successfulResolution.attempt.succeeded, coreResonance: successfulResolution.attempt.coreResonance, damage: successfulResolution.result?.damage ?? 0 } : null, coreStats }; } finally { original.units.forEach(({ unit, x, y }) => Object.assign(unit, { x, y })); target.hp = original.targetHp; scene.battleStats = original.battleStats; scene.attackIntents = original.attackIntents; scene.actedUnitIds = original.actedUnitIds; } }); assert( secondBattleCorePriority.ready === true && secondBattleCorePriority.candidate?.bondId === 'liu-bei__zhang-fei' && secondBattleCorePriority.candidate.partnerId === 'zhang-fei' && secondBattleCorePriority.candidate.rate === 18 && secondBattleCorePriority.candidate.coreResonance === true && secondBattleCorePriority.candidate.label.includes('핵심 공명') && secondBattleCorePriority.preview?.bondId === 'liu-bei__zhang-fei' && secondBattleCorePriority.preview.partnerId === 'zhang-fei' && secondBattleCorePriority.preview.rate === 18 && secondBattleCorePriority.preview.coreResonance === true && secondBattleCorePriority.failedResolution?.succeeded === false && secondBattleCorePriority.failedResolution.coreResonance === true && secondBattleCorePriority.failedResolution.hasResult === false && secondBattleCorePriority.successfulResolution?.succeeded === true && secondBattleCorePriority.successfulResolution.coreResonance === true && secondBattleCorePriority.successfulResolution.damage > 0 && sameJsonValue(secondBattleCorePriority.coreStats, { attempts: 2, successes: 1, failures: 1, additionalDamage: secondBattleCorePriority.successfulResolution.damage }), `Expected the designated Lv68 pair to outrank Liu Bei's ordinary Lv72 pursuit candidate: ${JSON.stringify(secondBattleCorePriority)}` ); 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; delete campaign.sortieResonanceSelection; window.localStorage.setItem(campaignSlotKey, JSON.stringify(campaign)); scene.launchSortieResonanceBondId = undefined; scene.coreResonancePursuitAttempts = 0; scene.coreResonancePursuitSuccesses = 0; scene.coreResonancePursuitFailures = 0; scene.coreResonancePursuitDamage = 0; 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(); const changedCoreResonance = structuredClone( window.__HEROS_DEBUG__?.battle()?.coreSortieResonance ?? null ); 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.launchSortieResonanceBondId = undefined; scene.coreResonancePursuitAttempts = 0; scene.coreResonancePursuitSuccesses = 0; scene.coreResonancePursuitFailures = 0; scene.coreResonancePursuitDamage = 0; scene.loadBattleState?.(1); const restored = captureUnits(); const restoredCoreResonance = structuredClone( window.__HEROS_DEBUG__?.battle()?.coreSortieResonance ?? null ); storageSnapshot.forEach((value, key) => { if (value === null) { window.localStorage.removeItem(key); } else { window.localStorage.setItem(key, value); } }); return { changed, changedCoreResonance, allReserve, restored, restoredCoreResonance }; }); 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?.changedCoreResonance?.selectedBondId === 'liu-bei__zhang-fei' && roleSealResyncProbe.changedCoreResonance.active === true && sameJsonValue(roleSealResyncProbe.changedCoreResonance.stats, { attempts: 2, successes: 1, failures: 1, additionalDamage: secondBattleCorePriority.successfulResolution.damage }), `Expected battle save data—not the cleared in-memory or campaign fallback—to restore the core designation and totals: ${JSON.stringify(roleSealResyncProbe?.changedCoreResonance)}` ); 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)}` ); assert( roleSealResyncProbe?.restoredCoreResonance?.selectedBondId === 'liu-bei__zhang-fei' && roleSealResyncProbe.restoredCoreResonance.active === true && roleSealResyncProbe.restoredCoreResonance.rate === 18 && sameJsonValue(roleSealResyncProbe.restoredCoreResonance.stats, { attempts: 2, successes: 1, failures: 1, additionalDamage: secondBattleCorePriority.successfulResolution.damage }), `Expected battle-slot reloads to preserve the selected core pair and its accumulated pursuit statistics: ${JSON.stringify(roleSealResyncProbe?.restoredCoreResonance)}` ); await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.resultVisible === true, undefined, { timeout: 30000 }); const secondBattleCoreResult = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const resonance = window.__HEROS_DEBUG__?.battle()?.coreSortieResonance; const texts = (scene?.resultObjects ?? []) .filter((object) => object?.type === 'Text') .map((object) => object.text); return { stats: resonance?.stats ?? null, resultSummary: resonance?.resultSummary ?? null, summary: texts.find((text) => text.startsWith('핵심 공명 · 시도')) ?? null }; }); await page.screenshot({ path: `${screenshotDir}/rc-second-battle-core-resonance-result.png`, fullPage: true }); assert( secondBattleCoreResult.summary === `핵심 공명 · 시도 2 · 성공 1 · 추가 피해 +${secondBattleCorePriority.successfulResolution.damage}` && sameJsonValue(secondBattleCoreResult.stats, roleSealResyncProbe.restoredCoreResonance.stats) && secondBattleCoreResult.resultSummary?.visible === true && secondBattleCoreResult.resultSummary.text === secondBattleCoreResult.summary && isFiniteBounds(secondBattleCoreResult.resultSummary.bounds) && boundsInside(secondBattleCoreResult.resultSummary.bounds, logicalViewportBounds) && secondBattleCoreResult.resultSummary.bounds.y + secondBattleCoreResult.resultSummary.bounds.height <= 416 * legacyUiScale, `Expected the victory report to surface persisted core-resonance attempts, successes, and damage: ${JSON.stringify(secondBattleCoreResult)}` ); const requiredCasualtySaveBefore = await readCampaignSave(page); try { await seedCampaignSave(page, { battleId: 'seventeenth-battle-wolong-visit-road', battleTitle: '융중 방문로', step: 'seventeenth-camp', gold: 2400, turnNumber: 12, defeatedEnemies: 10, selectedSortieUnitIds: ['liu-bei', 'zhuge-liang', 'zhao-yun', 'guan-yu', 'zhang-fei', 'sun-qian'] }); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); await clickLegacyUi(page, 962, 310); await waitForCamp(page); const requiredCasualtyRecruitment = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.camp(); const unit = state?.campaign?.roster?.find((candidate) => candidate.id === 'zhuge-liang'); return { nextBattleId: state?.nextSortieBattleId ?? null, unit: unit ? { id: unit.id, hp: unit.hp, maxHp: unit.maxHp } : null }; }); assert( requiredCasualtyRecruitment.nextBattleId === 'eighteenth-battle-bowang-ambush' && requiredCasualtyRecruitment.unit?.hp > 0, 'Expected the seventeenth camp to recruit Zhuge Liang before the casualty fixture: ' + JSON.stringify(requiredCasualtyRecruitment) ); const requiredCasualtySeed = await page.evaluate(() => { const keys = ['heros-web:campaign-state', 'heros-web:campaign-state:slot-1']; const seeded = []; for (const key of keys) { const raw = window.localStorage.getItem(key); const campaign = raw ? JSON.parse(raw) : null; const target = campaign?.roster?.find((unit) => unit.id === 'zhuge-liang'); if (!campaign || !target || !campaign.firstBattleReport) { seeded.push({ key, ready: false, rosterCount: campaign?.roster?.length ?? 0 }); continue; } target.hp = 0; const reportUnit = campaign.firstBattleReport.units?.find((unit) => unit.id === target.id); if (reportUnit) { reportUnit.hp = 0; } else { campaign.firstBattleReport.units = [...(campaign.firstBattleReport.units ?? []), { ...target, hp: 0 }]; } campaign.inventory = { ...(campaign.inventory ?? {}) }; const salveBefore = 2; campaign.inventory['상처약'] = salveBefore; campaign.selectedSortieUnitIds = (campaign.selectedSortieUnitIds ?? []).filter( (unitId) => unitId !== 'zhuge-liang' ); window.localStorage.setItem(key, JSON.stringify(campaign)); seeded.push({ key, ready: true, hp: target.hp, maxHp: target.maxHp, salveBefore, selected: campaign.selectedSortieUnitIds.includes(target.id) }); } return { ready: seeded.length === keys.length && seeded.every((entry) => entry.ready), seeded, salveBefore: seeded.find((entry) => entry.key === keys[0])?.salveBefore ?? null }; }); assert( requiredCasualtySeed.ready === true && requiredCasualtySeed.seeded.every((entry) => entry.hp === 0 && entry.maxHp > 0 && entry.selected === false), 'Expected current and slot saves to retain a zero-HP required Zhuge Liang outside the saved selection: ' + JSON.stringify(requiredCasualtySeed) ); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); await clickLegacyUi(page, 962, 310); await waitForCamp(page); const requiredCasualtyCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( requiredCasualtyCampState?.campaign?.step === 'seventeenth-camp' && requiredCasualtyCampState?.nextSortieBattleId === 'eighteenth-battle-bowang-ambush' && requiredCasualtyCampState?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang')?.hp === 0 && !requiredCasualtyCampState?.selectedSortieUnitIds?.includes('zhuge-liang'), 'Expected campaign loading to preserve the casualty while excluding it from the active sortie: ' + JSON.stringify(requiredCasualtyCampState) ); const requiredCasualtyStoryPoint = await readCampStaticTextControlPoint(page, '다음 이야기'); await page.mouse.click(requiredCasualtyStoryPoint.x, requiredCasualtyStoryPoint.y); await waitForSortiePrep(page); await advanceSortiePrepStep(page, 'formation'); const requiredCasualtyFormationState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const requiredCasualtyPortrait = requiredCasualtyFormationState?.sortiePortraitRoster?.find( (unit) => unit.id === 'zhuge-liang' ); assert( requiredCasualtyPortrait?.required === true && requiredCasualtyPortrait.selected === false && requiredCasualtyPortrait.available === false && requiredCasualtyPortrait.hp === 0 && requiredCasualtyPortrait.maxHp > 0 && requiredCasualtyPortrait.availabilityLabel === '부상' && requiredCasualtyPortrait.availabilityReason?.includes('보급 화면') && requiredCasualtyFormationState?.sortiePortraitRosterView?.visibleUnitIds?.includes('zhuge-liang') && requiredCasualtyFormationState?.sortiePrepSteps?.find((step) => step.id === 'formation')?.complete === false, 'Expected the unavailable required officer to stay visible, disabled, and formation-incomplete: ' + JSON.stringify({ portrait: requiredCasualtyPortrait, view: requiredCasualtyFormationState?.sortiePortraitRosterView, steps: requiredCasualtyFormationState?.sortiePrepSteps }) ); await page.screenshot({ path: screenshotDir + '/rc-required-sortie-casualty-card.png', fullPage: true }); await assertCanvasPainted(page, 'required sortie casualty card'); await advanceSortiePrepStep(page, 'loadout'); const requiredCasualtyLoadoutState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const requiredCasualtyReadiness = requiredCasualtyLoadoutState?.sortieChecklist?.find( (item) => item.label?.includes('유비') && item.label?.includes('제갈량') && item.label?.includes('생존') ); const requiredCasualtyComposition = requiredCasualtyLoadoutState?.sortieChecklist?.find( (item) => item.label === '출전 구성' ); const requiredCasualtyRecoveryTarget = requiredCasualtyLoadoutState?.sortieChecklist?.find( (item) => item.label === '부상 장수 확인' ); assert( requiredCasualtyReadiness?.complete === false && requiredCasualtyReadiness.detail?.includes('제갈량 0/') && requiredCasualtyReadiness.detail?.includes('회복 필요') && requiredCasualtyComposition?.complete === false && requiredCasualtyRecoveryTarget?.complete === false && requiredCasualtyRecoveryTarget.detail?.includes('제갈량 0/') && requiredCasualtyLoadoutState?.sortieChecklistSummary?.requiredComplete === false && requiredCasualtyLoadoutState?.sortieChecklistSummary?.requiredRemainingCount >= 2 && requiredCasualtyLoadoutState?.sortiePrepSteps?.find((step) => step.id === 'loadout')?.complete === false, 'Expected the casualty to keep both required final checks incomplete: ' + JSON.stringify({ readiness: requiredCasualtyReadiness, composition: requiredCasualtyComposition, recoveryTarget: requiredCasualtyRecoveryTarget, summary: requiredCasualtyLoadoutState?.sortieChecklistSummary, steps: requiredCasualtyLoadoutState?.sortiePrepSteps }) ); const requiredCasualtySaveBeforeLaunch = await readCampaignSave(page); const blockedLaunchPoint = await readSortieTextControlPoint(page, '출진'); await page.mouse.click(blockedLaunchPoint.x, blockedLaunchPoint.y); await page.waitForTimeout(120); const blockedRequiredLaunch = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const state = window.__HEROS_DEBUG__?.camp(); return { activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], sortieVisible: state?.sortieVisible ?? false, sortiePrepStep: state?.sortiePrepStep ?? null, notice: (scene?.dialogueObjects ?? []) .filter((object) => object?.type === 'Text' && object?.active && object?.visible) .map((object) => object.text) .join(' ') }; }); const requiredCasualtySaveAfterLaunch = await readCampaignSave(page); assert( blockedRequiredLaunch.activeScenes.includes('CampScene') && !blockedRequiredLaunch.activeScenes.includes('StoryScene') && !blockedRequiredLaunch.activeScenes.includes('BattleScene') && blockedRequiredLaunch.sortieVisible === true && blockedRequiredLaunch.sortiePrepStep === 'loadout' && blockedRequiredLaunch.notice.includes('제갈량') && blockedRequiredLaunch.notice.includes('보급 화면') && sameJsonValue(requiredCasualtySaveAfterLaunch, requiredCasualtySaveBeforeLaunch), 'Expected the real launch control to block a zero-HP required officer without mutating saves: ' + JSON.stringify({ blockedRequiredLaunch, saveUnchanged: sameJsonValue(requiredCasualtySaveAfterLaunch, requiredCasualtySaveBeforeLaunch) }) ); await page.screenshot({ path: screenshotDir + '/rc-required-sortie-casualty-blocked.png', fullPage: true }); await assertCanvasPainted(page, 'required sortie casualty blocked'); const returnToCampPoint = await readSortieTextControlPoint(page, '군영으로'); await page.mouse.click(returnToCampPoint.x, returnToCampPoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.sortieVisible === false, undefined, { timeout: 30000 } ); await selectCampRosterUnit(page, 'zhuge-liang'); const suppliesTabPoint = await readCampTabControlPoint(page, 'supplies'); await page.mouse.click(suppliesTabPoint.x, suppliesTabPoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.activeTab === 'supplies', undefined, { timeout: 30000 } ); const salveUsePoint = await readCampSupplyUseControlPoint(page, '상처약'); await page.mouse.click(salveUsePoint.x, salveUsePoint.y); await page.waitForTimeout(120); const salveClickApplied = await page.evaluate(() => { const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang'); return target?.hp > 0; }); if (!salveClickApplied) { const salveFallback = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang'); const texts = (scene?.contentObjects ?? []).filter( (object) => object?.type === 'Text' && object?.active && object?.visible ); const supplyTitle = texts.find((object) => object.text?.startsWith('상처약 x')); const useText = texts .filter((object) => object.text === '사용' && object.input?.enabled) .sort((left, right) => Math.abs(left.y - (supplyTitle?.y ?? 0)) - Math.abs(right.y - (supplyTitle?.y ?? 0)))[0]; if (!scene || !target || !supplyTitle || !useText) { return { ready: false, targetHp: target?.hp ?? null, supplyTitle: supplyTitle?.text ?? null, useCount: texts.filter((object) => object.text === '사용').length }; } useText.emit('pointerdown'); return { ready: true, targetHp: target.hp, supplyTitle: supplyTitle.text }; }); assert( salveFallback.ready === true, 'Expected the visible salve control to expose its Phaser action after the canvas click fallback: ' + JSON.stringify(salveFallback) ); } await page.waitForFunction( () => { const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang'); return target?.hp > 0; }, undefined, { timeout: 30000 } ); const requiredCasualtyRecoverySave = await readCampaignSave(page); const recoveredCurrent = requiredCasualtyRecoverySave.current?.roster?.find((unit) => unit.id === 'zhuge-liang'); const recoveredSlot = requiredCasualtyRecoverySave.slot1?.roster?.find((unit) => unit.id === 'zhuge-liang'); const recoveredCurrentReport = requiredCasualtyRecoverySave.current?.firstBattleReport?.units?.find( (unit) => unit.id === 'zhuge-liang' ); const recoveredSlotReport = requiredCasualtyRecoverySave.slot1?.firstBattleReport?.units?.find( (unit) => unit.id === 'zhuge-liang' ); assert( recoveredCurrent?.hp > 0 && recoveredSlot?.hp === recoveredCurrent.hp && recoveredCurrentReport?.hp === recoveredCurrent.hp && recoveredSlotReport?.hp === recoveredCurrent.hp && (requiredCasualtyRecoverySave.current?.inventory?.['상처약'] ?? 0) === requiredCasualtySeed.salveBefore - 1 && (requiredCasualtyRecoverySave.slot1?.inventory?.['상처약'] ?? 0) === requiredCasualtySeed.salveBefore - 1, 'Expected one real salve use to recover and persist the required officer exactly once: ' + JSON.stringify({ currentHp: recoveredCurrent?.hp, slotHp: recoveredSlot?.hp, currentReportHp: recoveredCurrentReport?.hp, slotReportHp: recoveredSlotReport?.hp, currentSalves: requiredCasualtyRecoverySave.current?.inventory?.['상처약'] ?? 0, slotSalves: requiredCasualtyRecoverySave.slot1?.inventory?.['상처약'] ?? 0, expectedSalves: requiredCasualtySeed.salveBefore - 1 }) ); const recoveredStoryPoint = await readCampStaticTextControlPoint(page, '다음 이야기'); await page.mouse.click(recoveredStoryPoint.x, recoveredStoryPoint.y); await waitForSortiePrep(page); const recoveredRequiredBriefing = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( recoveredRequiredBriefing?.selectedSortieUnitIds?.includes('liu-bei') && recoveredRequiredBriefing?.selectedSortieUnitIds?.includes('zhuge-liang'), 'Expected reopening sortie prep to auto-select every recovered required officer: ' + JSON.stringify(recoveredRequiredBriefing?.selectedSortieUnitIds) ); await advanceSortiePrepStep(page, 'formation'); const recoveredRequiredFormation = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const recoveredRequiredPortrait = recoveredRequiredFormation?.sortiePortraitRoster?.find( (unit) => unit.id === 'zhuge-liang' ); assert( recoveredRequiredPortrait?.required === true && recoveredRequiredPortrait.selected === true && recoveredRequiredPortrait.available === true && recoveredRequiredPortrait.hp === recoveredCurrent?.hp && recoveredRequiredFormation?.sortiePrepSteps?.find((step) => step.id === 'formation')?.complete === true, 'Expected the recovered required portrait and formation stage to become ready: ' + JSON.stringify({ portrait: recoveredRequiredPortrait, steps: recoveredRequiredFormation?.sortiePrepSteps }) ); await page.screenshot({ path: screenshotDir + '/rc-required-sortie-casualty-recovered.png', fullPage: true }); await assertCanvasPainted(page, 'required sortie casualty recovered'); await advanceSortiePrepStep(page, 'loadout'); const recoveredRequiredLoadout = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const recoveredReadiness = recoveredRequiredLoadout?.sortieChecklist?.find( (item) => item.label?.includes('유비') && item.label?.includes('제갈량') && item.label?.includes('생존') ); const recoveredComposition = recoveredRequiredLoadout?.sortieChecklist?.find( (item) => item.label === '출전 구성' ); const recoveredRecoveryTarget = recoveredRequiredLoadout?.sortieChecklist?.find( (item) => item.label === '부상 장수 확인' ); const recoveredDeploymentIds = recoveredRequiredLoadout?.sortieDeploymentPreview?.map((slot) => slot.unitId) ?? []; assert( recoveredReadiness?.complete === true && recoveredComposition?.complete === true && recoveredRecoveryTarget?.complete === true && recoveredRequiredLoadout?.sortieChecklistSummary?.requiredComplete === true && recoveredDeploymentIds.filter((unitId) => unitId === 'liu-bei').length === 1 && recoveredDeploymentIds.filter((unitId) => unitId === 'zhuge-liang').length === 1 && new Set(recoveredDeploymentIds).size === recoveredDeploymentIds.length, 'Expected recovery to complete required checks and produce one unique slot per required officer: ' + JSON.stringify({ recoveredReadiness, recoveredComposition, recoveredRecoveryTarget, summary: recoveredRequiredLoadout?.sortieChecklistSummary, deployment: recoveredRequiredLoadout?.sortieDeploymentPreview }) ); const recoveredLaunchPoint = await readSortieTextControlPoint(page, '출진'); await page.mouse.click(recoveredLaunchPoint.x, recoveredLaunchPoint.y); await page.waitForFunction( () => (window.__HEROS_DEBUG__?.activeScenes() ?? []).includes('StoryScene'), undefined, { timeout: 30000 } ); const recoveredRequiredLaunchSave = await readCampaignSave(page); assert( recoveredRequiredLaunchSave.current?.step === 'eighteenth-battle' && recoveredRequiredLaunchSave.slot1?.step === 'eighteenth-battle' && recoveredRequiredLaunchSave.current?.selectedSortieUnitIds?.includes('liu-bei') && recoveredRequiredLaunchSave.current?.selectedSortieUnitIds?.includes('zhuge-liang'), 'Expected the same real launch control to continue after the required officer recovers: ' + JSON.stringify({ currentStep: recoveredRequiredLaunchSave.current?.step, slotStep: recoveredRequiredLaunchSave.slot1?.step, selected: recoveredRequiredLaunchSave.current?.selectedSortieUnitIds }) ); } finally { await page.evaluate((save) => { const write = (key, value) => { if (value === undefined) { window.localStorage.removeItem(key); return; } window.localStorage.setItem(key, JSON.stringify(value)); }; write('heros-web:campaign-state', save.current); write('heros-web:campaign-state:slot-1', save.slot1); }, requiredCasualtySaveBefore); const requiredCasualtyRestoredSave = await readCampaignSave(page); assert( sameJsonValue(requiredCasualtyRestoredSave, requiredCasualtySaveBefore), 'Expected the required-casualty fixture to restore both campaign saves: ' + JSON.stringify({ before: requiredCasualtySaveBefore, after: requiredCasualtyRestoredSave }) ); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); } 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 clickLegacyUi(page, 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 clickLegacyUi(page, 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 === 23 && midBriefingState?.sortiePortraitRosterView?.visibleCount === 6 && midBriefingState?.sortiePortraitRosterView?.start === 1 && midBriefingState?.sortiePortraitRosterView?.end === 6, `Expected the mid-campaign staged formation to expose the first six-card portrait roster page: ${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 midPortraitsMissingStrategyCopy = midFormationPortraitState?.sortiePortraitRoster?.filter( (unit) => !Array.isArray(unit.strategyNames) || unit.strategyNames.length === 0 || !unit.strategyLine?.startsWith('전법 ') ) ?? []; assert( midPortraitsMissingStrategyCopy.length === 0 && sameJsonValue(midFormationPortraitState?.sortiePortraitRoster?.find((unit) => unit.id === 'guan-yu')?.strategyNames, ['화계']) && sameJsonValue(midFormationPortraitState?.sortiePortraitRoster?.find((unit) => unit.id === 'zhang-fei')?.strategyNames, ['고함']), `Expected every sortie portrait card to expose its battle strategies: ${JSON.stringify(midPortraitsMissingStrategyCopy)}` ); assert( midFormationPortraitState?.sortieStrategyCoverage?.count === 4 && midFormationPortraitState.sortieStrategyCoverage.total === 4 && midFormationPortraitState.sortieStrategyCoverage.missing.length === 0 && sameJsonValue(midFormationPortraitState.sortieStrategyCoverage.covered.map((definition) => definition.label), ['회복', '강화', '화공', '제어']) && midFormationPortraitState?.sortieFocusedUnit?.strategyLine?.startsWith('전법 '), `Expected the selected formation and focused officer to expose complete strategy coverage: ${JSON.stringify({ coverage: midFormationPortraitState?.sortieStrategyCoverage, focused: midFormationPortraitState?.sortieFocusedUnit })}` ); const midCoreSelectorToggle = midFormationPortraitState?.sortiePursuitPreview; assert( midCoreSelectorToggle?.selectorOpen === false && midCoreSelectorToggle.toggleLabel === '핵심 공명조' && isFiniteBounds(midCoreSelectorToggle.toggleBounds) && isFiniteBounds(midCoreSelectorToggle.toggleClickBounds) && boundsInside(midCoreSelectorToggle.toggleBounds, midCoreSelectorToggle.panelBounds), `Expected the generic formation comparison panel to expose an in-panel core-resonance mode switch: ${JSON.stringify(midCoreSelectorToggle)}` ); await page.mouse.click( midCoreSelectorToggle.toggleClickBounds.x + midCoreSelectorToggle.toggleClickBounds.width / 2, midCoreSelectorToggle.toggleClickBounds.y + midCoreSelectorToggle.toggleClickBounds.height / 2 ); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.selectorOpen === true, undefined, { timeout: 30000 } ); const midCoreSelectorState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const midCoreResonancePageOne = midCoreSelectorState?.sortiePursuitPreview; assert( midCoreResonancePageOne?.coreCandidates?.length > 3 && midCoreResonancePageOne.selectorOpen === true && midCoreResonancePageOne.toggleLabel === '편성 비교' && midCoreResonancePageOne.page === 0 && midCoreResonancePageOne.pageCount === Math.ceil(midCoreResonancePageOne.coreCandidates.length / 3) && midCoreResonancePageOne.rows.length === 3 && midCoreResonancePageOne.prevEnabled === false && midCoreResonancePageOne.nextEnabled === true && midCoreResonancePageOne.prevClickBounds === null && isFiniteBounds(midCoreResonancePageOne.nextClickBounds) && boundsInside(midCoreResonancePageOne.nextBounds, midCoreResonancePageOne.panelBounds), `Expected the mid-campaign formation to paginate more than three eligible core-resonance pairs: ${JSON.stringify(midCoreResonancePageOne)}` ); const midCoreFirstPageIds = midCoreResonancePageOne.rows.map((row) => row.bondId); await page.mouse.click( midCoreResonancePageOne.nextClickBounds.x + midCoreResonancePageOne.nextClickBounds.width / 2, midCoreResonancePageOne.nextClickBounds.y + midCoreResonancePageOne.nextClickBounds.height / 2 ); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.page === 1, undefined, { timeout: 30000 } ); const midCoreResonancePageTwo = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-core-resonance-page-2.png`, fullPage: true }); assert( midCoreResonancePageTwo?.page === 1 && midCoreResonancePageTwo.pageText === `2/${midCoreResonancePageTwo.pageCount}` && midCoreResonancePageTwo.prevEnabled === true && isFiniteBounds(midCoreResonancePageTwo.prevClickBounds) && midCoreResonancePageTwo.rows.length > 0 && midCoreResonancePageTwo.rows.every( (row) => !midCoreFirstPageIds.includes(row.bondId) && isFiniteBounds(row.chipBounds) && boundsInside(row.chipBounds, midCoreResonancePageTwo.panelBounds) ), `Expected the next-page control to expose later eligible resonance pairs inside the FHD panel: ${JSON.stringify(midCoreResonancePageTwo)}` ); const midCoreLaterCandidate = midCoreResonancePageTwo.rows[0]; await page.mouse.click( midCoreLaterCandidate.chipBounds.x + midCoreLaterCandidate.chipBounds.width / 2, midCoreLaterCandidate.chipBounds.y + midCoreLaterCandidate.chipBounds.height / 2 ); await page.waitForFunction( (bondId) => { const pursuit = window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview; return pursuit?.selectedCoreBondId === bondId && pursuit?.page === 0 && pursuit?.rows?.[0]?.bondId === bondId; }, midCoreLaterCandidate.bondId, { timeout: 30000 } ); const midCoreLaterSelected = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); assert( midCoreLaterSelected?.selectedCoreBondId === midCoreLaterCandidate.bondId && midCoreLaterSelected.page === 0 && midCoreLaterSelected.rows[0]?.bondId === midCoreLaterCandidate.bondId && midCoreLaterSelected.rows[0].selected === true && midCoreLaterSelected.rows[0].state === 'selected', `Expected a pair reached through page two to become the visible persisted core designation: ${JSON.stringify(midCoreLaterSelected)}` ); await page.mouse.click( midCoreLaterSelected.rows[0].chipBounds.x + midCoreLaterSelected.rows[0].chipBounds.width / 2, midCoreLaterSelected.rows[0].chipBounds.y + midCoreLaterSelected.rows[0].chipBounds.height / 2 ); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.selectedCoreBondId === null, undefined, { timeout: 30000 } ); const midCoreSelectorAfterClear = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); await page.mouse.click( midCoreSelectorAfterClear.toggleClickBounds.x + midCoreSelectorAfterClear.toggleClickBounds.width / 2, midCoreSelectorAfterClear.toggleClickBounds.y + midCoreSelectorAfterClear.toggleClickBounds.height / 2 ); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.selectorOpen === false, undefined, { timeout: 30000 } ); 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 })}` ); const maDaiCardPoint = await readSortiePortraitRosterCardPoint(page, 'ma-dai'); await page.mouse.click(maDaiCardPoint.x, maDaiCardPoint.y); 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)}` ); const guanYuHoverPoint = await readSortiePortraitRosterCardPoint(page, 'guan-yu'); await page.mouse.move(guanYuHoverPoint.x, guanYuHoverPoint.y); 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 && swapPreview?.strategyCoverage?.current?.count === 4 && swapPreview?.strategyCoverage?.projected?.count === 4 && swapPreview?.strategyCoverage?.current?.total === 4 && swapPreview?.strategyCoverage?.projected?.total === 4 && swapPreview?.strategyCoverage?.delta === 0 && swapPreview?.strategyCoverage?.trend === 'even' && swapPreview?.strategyCoverage?.gained?.length === 0 && swapPreview?.strategyCoverage?.lost?.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('전법 4/4→4/4') && midFormationSwapPreviewProbe.panel.impact?.includes('추격') && midFormationSwapPreviewProbe.panel.impact?.includes('공백 돌파') && midFormationSwapPreviewProbe.panel.detail?.includes('전법 유지') && midFormationSwapPreviewProbe.panel.detail?.includes('추격 해제') && midFormationSwapPreviewProbe.panel.detail?.includes('8%') && 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 moveLegacyUi(page, 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)}` ); const guanYuSwapPoint = await readSortiePortraitRosterCardPoint(page, 'guan-yu'); await page.mouse.move(guanYuSwapPoint.x, guanYuSwapPoint.y); 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(guanYuSwapPoint.x, guanYuSwapPoint.y); 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 openPresetBookForSiegeFixturePoint = await readSortiePresetControlPoint(page, 'toggle'); await page.mouse.click(openPresetBookForSiegeFixturePoint.x, openPresetBookForSiegeFixturePoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.sortiePresetBrowser?.open === true, undefined, { timeout: 30000 } ); const saveSiegePresetPoint = await readSortiePresetControlPoint(page, 'save', 'siege'); await page.mouse.click(saveSiegePresetPoint.x, saveSiegePresetPoint.y); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.camp(); return state?.sortiePresetBrowser?.open === true && state?.sortieFormationPresets?.siege?.selectedUnitIds?.length > 0; }, undefined, { timeout: 30000 }); const savedSiegeFixtureState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const savedSiegeFixtureSave = await readCampaignSave(page); const savedSiegePreset = savedSiegeFixtureState?.sortieFormationPresets?.siege; assert( savedSiegePreset?.selectedUnitIds?.length === savedSiegeFixtureState?.selectedSortieUnitIds?.length && savedSiegePreset.selectedUnitIds.every((unitId) => Boolean(savedSiegePreset.formationAssignments?.[unitId])) && JSON.stringify(Object.keys(savedSiegePreset).sort()) === JSON.stringify(['formationAssignments', 'selectedUnitIds']) && savedSiegePreset.sortieItemAssignments === undefined && savedSiegePreset.itemAssignments === undefined && sameJsonValue(savedSiegeFixtureSave.current?.sortieFormationPresets?.siege, savedSiegePreset) && sameJsonValue(savedSiegeFixtureSave.slot1?.sortieFormationPresets?.siege, savedSiegePreset), `Expected a deterministic pre-battle siege preset fixture containing only the current roster and roles: ${JSON.stringify({ state: savedSiegeFixtureState, save: savedSiegeFixtureSave })}` ); const closePresetBookAfterSiegeFixturePoint = await readSortiePresetControlPoint(page, 'close'); await page.mouse.click(closePresetBookAfterSiegeFixturePoint.x, closePresetBookAfterSiegeFixturePoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.sortiePresetBrowser?.open === false, undefined, { timeout: 30000 } ); const midPortraitUndoRestoredPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); assert( midPortraitUndoRestoredPage?.page === 1 && midPortraitUndoRestoredPage.scroll === midPortraitUndoRestoredPage.pageSize && midPortraitUndoRestoredPage.previousEnabled === true && isFiniteBounds(midPortraitUndoRestoredPage.previousButtonBounds), `Expected swap undo to restore the second portrait page before the independent paging roundtrip: ${JSON.stringify(midPortraitUndoRestoredPage)}` ); const midPortraitPagingResetPoint = await readSortiePortraitRosterNavigationPoint(page, 'previous'); await page.mouse.click(midPortraitPagingResetPoint.x, midPortraitPagingResetPoint.y); await page.waitForFunction(() => { const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView; return view?.page === 0 && view.scroll === 0 && view.previousEnabled === false && view.visibleUnitIds?.length === 6 && view.cardBounds?.length === 6; }, undefined, { timeout: 30000 }); const midPortraitPagingBaseline = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.camp(); return { view: state?.sortiePortraitRosterView ?? null, allUnitIds: state?.sortiePortraitRoster?.map((unit) => unit.id) ?? [], focusedUnitId: state?.sortieFocusedUnitId ?? null, selectedUnitIds: state?.selectedSortieUnitIds ?? [], campaignSelectedUnitIds: state?.campaign?.selectedSortieUnitIds ?? [], formationAssignments: state?.sortieFormationAssignments ?? {}, itemAssignments: state?.sortieItemAssignments ?? {} }; }); const midPortraitPagingSaveBefore = await readCampaignSave(page); assert( midPortraitPagingBaseline.allUnitIds.length === 23 && new Set(midPortraitPagingBaseline.allUnitIds).size === 23, `Expected the portrait paging fixture to contain exactly 23 unique campaign officers: ${JSON.stringify(midPortraitPagingBaseline)}` ); const midPortraitForwardUnitIds = []; let midPortraitCurrentView = midPortraitPagingBaseline.view; for (let expectedPage = 0; expectedPage < 4; expectedPage += 1) { const expectedVisibleUnitIds = midPortraitPagingBaseline.allUnitIds.slice(expectedPage * 6, expectedPage * 6 + 6); assertSortiePortraitRosterPage(midPortraitCurrentView, expectedPage, expectedVisibleUnitIds); midPortraitForwardUnitIds.push(...midPortraitCurrentView.visibleUnitIds); if (expectedPage < 3) { const nextPoint = await readSortiePortraitRosterNavigationPoint(page, 'next'); await page.mouse.click(nextPoint.x, nextPoint.y); await page.waitForFunction( ({ pageIndex, visibleUnitIds }) => { const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView; return view?.page === pageIndex && JSON.stringify(view.visibleUnitIds) === JSON.stringify(visibleUnitIds); }, { pageIndex: expectedPage + 1, visibleUnitIds: midPortraitPagingBaseline.allUnitIds.slice((expectedPage + 1) * 6, (expectedPage + 2) * 6) }, { timeout: 30000 } ); midPortraitCurrentView = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); } } assert( midPortraitForwardUnitIds.length === 23 && new Set(midPortraitForwardUnitIds).size === 23 && sameJsonValue(midPortraitForwardUnitIds, midPortraitPagingBaseline.allUnitIds) && midPortraitCurrentView.visibleUnitIds.length === 5, `Expected four portrait pages to expose all 23 officers exactly once and finish with five officers: ${JSON.stringify({ expected: midPortraitPagingBaseline.allUnitIds, actual: midPortraitForwardUnitIds, lastPage: midPortraitCurrentView })}` ); await page.waitForTimeout(300); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-roster-last-page.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp sortie portrait roster last page'); for (let expectedPage = 2; expectedPage >= 0; expectedPage -= 1) { const previousPoint = await readSortiePortraitRosterNavigationPoint(page, 'previous'); await page.mouse.click(previousPoint.x, previousPoint.y); const expectedVisibleUnitIds = midPortraitPagingBaseline.allUnitIds.slice(expectedPage * 6, expectedPage * 6 + 6); await page.waitForFunction( ({ pageIndex, visibleUnitIds }) => { const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView; return view?.page === pageIndex && JSON.stringify(view.visibleUnitIds) === JSON.stringify(visibleUnitIds); }, { pageIndex: expectedPage, visibleUnitIds: expectedVisibleUnitIds }, { timeout: 30000 } ); midPortraitCurrentView = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); assertSortiePortraitRosterPage(midPortraitCurrentView, expectedPage, expectedVisibleUnitIds); } const midPortraitPagingAfter = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.camp(); return { focusedUnitId: state?.sortieFocusedUnitId ?? null, selectedUnitIds: state?.selectedSortieUnitIds ?? [], campaignSelectedUnitIds: state?.campaign?.selectedSortieUnitIds ?? [], formationAssignments: state?.sortieFormationAssignments ?? {}, itemAssignments: state?.sortieItemAssignments ?? {} }; }); const midPortraitPagingSaveAfter = await readCampaignSave(page); assert( midPortraitCurrentView?.page === 0 && midPortraitPagingAfter.focusedUnitId === midPortraitPagingBaseline.focusedUnitId && sameJsonValue(midPortraitPagingAfter.selectedUnitIds, midPortraitPagingBaseline.selectedUnitIds) && sameJsonValue(midPortraitPagingAfter.campaignSelectedUnitIds, midPortraitPagingBaseline.campaignSelectedUnitIds) && sameJsonValue(midPortraitPagingAfter.formationAssignments, midPortraitPagingBaseline.formationAssignments) && sameJsonValue(midPortraitPagingAfter.itemAssignments, midPortraitPagingBaseline.itemAssignments) && sameJsonValue(midPortraitPagingSaveAfter, midPortraitPagingSaveBefore), `Expected portrait paging to return to page one without changing focus, selection, formation, supplies, or saves: ${JSON.stringify({ before: midPortraitPagingBaseline, after: midPortraitPagingAfter, saveBefore: midPortraitPagingSaveBefore, saveAfter: midPortraitPagingSaveAfter })}` ); 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 clickLegacyUi(page, 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 clickLegacyUi(page, 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 clickLegacyUi(page, 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 midResultSortieReviewBeforePresetUpdate = structuredClone( midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortieReview ); 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 })}` ); assert( midResultSortieReviewBeforePresetUpdate?.version === 1 && midResultSortieReviewBeforePresetUpdate.score === midResultState?.sortieEvaluation?.score && midResultSortieReviewBeforePresetUpdate.grade === midResultState?.sortieEvaluation?.grade && midResultSortieReviewBeforePresetUpdate.activeBondCount === midResultState?.sortieEvaluation?.activeBondCount && midResultSortieReviewBeforePresetUpdate.enemyCount === midNextBattleProbe.units.filter((unit) => unit.faction === 'enemy').length && sameJsonValue(midResultSaveBeforeUpdate.current?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) && sameJsonValue(midResultSaveBeforeUpdate.slot1?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) && sameJsonValue(midResultSaveBeforeUpdate.slot1?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate), `Expected the mid-campaign result to persist one canonical sortie review before any result-screen preset update: ${JSON.stringify({ review: midResultSortieReviewBeforePresetUpdate, evaluation: midResultState?.sortieEvaluation })}` ); 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?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) && sameJsonValue(updatedMobileResultSave.slot1?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) && sameJsonValue(updatedMobileResultSave.current?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) && sameJsonValue(updatedMobileResultSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) && 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'); const closeMidBattleEvaluationPoint = await readBattleResultEvaluationControlPoint(page, 'close'); await page.mouse.click(closeMidBattleEvaluationPoint.x, closeMidBattleEvaluationPoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false, undefined, { timeout: 30000 } ); await clickLegacyUi(page, 738, 642); await waitForCampAfterBattleResult(page); const midCampReviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const midCampReviewSaveBefore = await readCampaignSave(page); const midCampFormationEvaluation = midCampReviewState?.reportFormationEvaluation; const midCampMobileCard = midCampFormationEvaluation?.presetCards?.find((card) => card.id === 'mobile'); const midCampSiegeCard = midCampFormationEvaluation?.presetCards?.find((card) => card.id === 'siege'); assert( midCampFormationEvaluation?.available === true && midCampFormationEvaluation.open === false && midCampFormationEvaluation.battleId === midCampNextBattleId && midCampFormationEvaluation.grade === updatedMobileResultState?.sortieEvaluation?.grade && midCampFormationEvaluation.score === updatedMobileResultState?.sortieEvaluation?.score && sameJsonValue(midCampFormationEvaluation.snapshot, midResultPerformance) && sameJsonValue(midCampFormationEvaluation.sourcePresetIds, midResultSortieReviewBeforePresetUpdate.sourcePresetIds) && midCampMobileCard?.saved === true && midCampMobileCard.matching === true && midCampSiegeCard?.saved === true && midCampSiegeCard.matching === false && midCampFormationEvaluation.toggleButtonBounds && !sameJsonValue(savedSiegePreset, expectedMidResultPreset), `Expected the next camp report to reopen the persisted result while keeping the older siege preset distinct: ${JSON.stringify({ evaluation: midCampFormationEvaluation, savedSiegePreset })}` ); const midCampReviewTogglePoint = await readCampReportFormationEvaluationControlPoint(page, 'toggle'); await page.mouse.click(midCampReviewTogglePoint.x, midCampReviewTogglePoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation?.open === true, undefined, { timeout: 30000 } ); const openedMidCampReviewProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); return { state: window.__HEROS_DEBUG__?.camp(), texts: (scene?.reportFormationReviewObjects ?? []) .filter((object) => object?.type === 'Text') .map((object) => object.text) }; }); const openedMidCampReviewSave = await readCampaignSave(page); const openedMidCampSiegeCard = openedMidCampReviewProbe.state?.reportFormationEvaluation?.presetCards?.find( (card) => card.id === 'siege' ); assert( openedMidCampReviewProbe.state?.reportFormationEvaluation?.open === true && openedMidCampReviewProbe.texts.includes('최근 전투 편성 평가') && openedMidCampReviewProbe.texts.some((text) => text.includes('공성') && text.includes('갱신')) && openedMidCampSiegeCard?.saved === true && openedMidCampSiegeCard.matching === false && openedMidCampSiegeCard.actionButtonBounds && sameJsonValue(openedMidCampReviewSave, midCampReviewSaveBefore), `Expected opening the camp report evaluation to remain non-destructive and expose the siege update action: ${JSON.stringify(openedMidCampReviewProbe)}` ); const firstCampSiegeUpdatePoint = await readCampReportFormationEvaluationControlPoint(page, 'preset', 'siege'); await page.mouse.click(firstCampSiegeUpdatePoint.x, firstCampSiegeUpdatePoint.y); await page.waitForFunction(() => { const evaluation = window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation; return evaluation?.open === true && evaluation?.overwriteConfirmId === 'siege' && evaluation?.feedback?.includes('한 번 더'); }, undefined, { timeout: 30000 }); const pendingCampSiegeUpdateState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const pendingCampSiegeUpdateSave = await readCampaignSave(page); assert( sameJsonValue( pendingCampSiegeUpdateState?.reportFormationEvaluation?.sourcePresetIds, midResultSortieReviewBeforePresetUpdate.sourcePresetIds ) && pendingCampSiegeUpdateState?.reportFormationEvaluation?.presetCards?.find((card) => card.id === 'siege')?.matching === false && sameJsonValue(pendingCampSiegeUpdateSave, midCampReviewSaveBefore), `Expected the first camp siege update click to request confirmation without mutating either save: ${JSON.stringify({ evaluation: pendingCampSiegeUpdateState?.reportFormationEvaluation, save: pendingCampSiegeUpdateSave })}` ); const confirmCampSiegeUpdatePoint = await readCampReportFormationEvaluationControlPoint(page, 'preset', 'siege'); await page.mouse.click(confirmCampSiegeUpdatePoint.x, confirmCampSiegeUpdatePoint.y); await page.waitForFunction(() => { const evaluation = window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation; return evaluation?.open === true && evaluation?.overwriteConfirmId === null && evaluation?.presetCards?.find((card) => card.id === 'siege')?.matching === true && evaluation?.feedback?.includes('갱신 완료'); }, undefined, { timeout: 30000 }); const updatedCampSiegeState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const updatedCampSiegeSave = await readCampaignSave(page); const updatedCurrentSiegePreset = updatedCampSiegeSave.current?.sortieFormationPresets?.siege; const updatedSlotSiegePreset = updatedCampSiegeSave.slot1?.sortieFormationPresets?.siege; assert( sameJsonValue(updatedCurrentSiegePreset, expectedMidResultPreset) && sameJsonValue(updatedSlotSiegePreset, expectedMidResultPreset) && JSON.stringify(Object.keys(updatedCurrentSiegePreset ?? {}).sort()) === JSON.stringify(['formationAssignments', 'selectedUnitIds']) && updatedCurrentSiegePreset?.sortieItemAssignments === undefined && updatedCurrentSiegePreset?.itemAssignments === undefined && updatedCurrentSiegePreset?.unitStats === undefined, `Expected the confirmed camp action to store only the reviewed roster and roles in both siege preset saves: ${JSON.stringify(updatedCampSiegeSave)}` ); assert( JSON.stringify(updatedCampSiegeSave.current?.selectedSortieUnitIds) === JSON.stringify(midCampReviewSaveBefore.current?.selectedSortieUnitIds) && sameJsonValue(updatedCampSiegeSave.current?.sortieFormationAssignments, midCampReviewSaveBefore.current?.sortieFormationAssignments) && sameJsonValue(updatedCampSiegeSave.current?.sortieItemAssignments, midCampReviewSaveBefore.current?.sortieItemAssignments) && JSON.stringify(updatedCampSiegeSave.slot1?.selectedSortieUnitIds) === JSON.stringify(midCampReviewSaveBefore.slot1?.selectedSortieUnitIds) && sameJsonValue(updatedCampSiegeSave.slot1?.sortieFormationAssignments, midCampReviewSaveBefore.slot1?.sortieFormationAssignments) && sameJsonValue(updatedCampSiegeSave.slot1?.sortieItemAssignments, midCampReviewSaveBefore.slot1?.sortieItemAssignments) && sameJsonValue(updatedCampSiegeSave.current?.firstBattleReport?.sortiePerformance, midResultPerformance) && sameJsonValue(updatedCampSiegeSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) && sameJsonValue(updatedCampSiegeSave.current?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) && sameJsonValue(updatedCampSiegeSave.slot1?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) && sameJsonValue(updatedCampSiegeSave.current?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) && sameJsonValue(updatedCampSiegeSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) && sameJsonValue(updatedCampSiegeSave.current?.sortieFormationPresets?.mobile, midCampReviewSaveBefore.current?.sortieFormationPresets?.mobile) && sameJsonValue(updatedCampSiegeSave.current?.sortieFormationPresets?.elite, midCampReviewSaveBefore.current?.sortieFormationPresets?.elite) && JSON.stringify(updatedCampSiegeState?.selectedSortieUnitIds) === JSON.stringify(midCampReviewState?.selectedSortieUnitIds) && sameJsonValue(updatedCampSiegeState?.sortieFormationAssignments, midCampReviewState?.sortieFormationAssignments) && sameJsonValue(updatedCampSiegeState?.sortieItemAssignments, midCampReviewState?.sortieItemAssignments), `Expected the camp review update to preserve the active sortie, supplies, report snapshot, and other presets: ${JSON.stringify({ before: midCampReviewSaveBefore, after: updatedCampSiegeSave })}` ); assert( updatedCampSiegeState?.reportFormationEvaluation?.presetCards?.find((card) => card.id === 'siege')?.matching === true && updatedCampSiegeState?.reportFormationEvaluation?.feedback?.includes('장비·보급은 저장하지 않았습니다.'), `Expected visible completion feedback and a matching siege card after the camp update: ${JSON.stringify(updatedCampSiegeState?.reportFormationEvaluation)}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-report-formation-evaluation-updated.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp report formation evaluation update'); const closeUpdatedCampReviewPoint = await readCampReportFormationEvaluationControlPoint(page, 'close'); await page.mouse.click(closeUpdatedCampReviewPoint.x, closeUpdatedCampReviewPoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation?.open === false, undefined, { timeout: 30000 } ); 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 clickLegacyUi(page, 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( finalCampState.reportFormationEvaluation?.available === false && finalCampState.reportFormationEvaluation.open === false && finalCampState.reportFormationEvaluation.toggleButtonBounds === null && finalCampState.reportFormationEvaluation.presetCards?.length === 0, `Expected a latest report without sortie performance to hide the camp evaluation instead of falling back to older history: ${JSON.stringify(finalCampState.reportFormationEvaluation)}` ); assert( Array.isArray(finalCampState.sortieDeploymentPreview) && finalCampState.sortieDeploymentPreview.length === 0, `Expected final camp deployment preview to stay empty: ${JSON.stringify(finalCampState?.sortieDeploymentPreview)}` ); await captureStableCampScreenshot(page, `${screenshotDir}/rc-final-camp.png`, 0, 'final camp'); await assertFinalCampRosterPagination(page); await clickLegacyUi(page, 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 clickLegacyUi(page, 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(); } async function setDebugQueryParam(page, key, value) { await page.evaluate(({ key, value }) => { const url = new URL(window.location.href); if (value === null) { url.searchParams.delete(key); } else { url.searchParams.set(key, value); } window.history.replaceState(window.history.state, '', url); }, { key, value }); } 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 assertHdPlusCanvasFit(page) { const cases = [ { viewport: { width: 1920, height: 1080 }, expectedBounds: { x: 0, y: 0, width: 1920, height: 1080 } }, { viewport: { width: 2560, height: 1440 }, expectedBounds: { x: 0, y: 0, width: 2560, height: 1440 } }, { viewport: { width: 2560, height: 1600 }, expectedBounds: { x: 0, y: 80, width: 2560, height: 1440 } } ]; for (const testCase of cases) { await page.setViewportSize(testCase.viewport); await page.waitForFunction(({ expectedBounds }) => { const canvas = document.querySelector('canvas'); if (!canvas) { return false; } const bounds = canvas.getBoundingClientRect(); return ( canvas.width === 1920 && canvas.height === 1080 && Math.abs(bounds.x - expectedBounds.x) <= 1 && Math.abs(bounds.y - expectedBounds.y) <= 1 && Math.abs(bounds.width - expectedBounds.width) <= 1 && Math.abs(bounds.height - expectedBounds.height) <= 1 ); }, testCase, { timeout: 30000 }); const state = await page.evaluate(() => { const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); const game = window.__HEROS_GAME__; return { viewport: { width: window.innerWidth, height: window.innerHeight }, gameSize: game ? { width: game.scale.width, height: game.scale.height } : null, canvas: canvas && bounds ? { width: canvas.width, height: canvas.height, bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } } : null }; }); assert(state.canvas?.width === 1920 && state.canvas?.height === 1080, `Expected a fixed FHD game surface: ${JSON.stringify(state)}`); assert(state.gameSize?.width === 1920 && state.gameSize?.height === 1080, `Expected fixed FHD scene coordinates: ${JSON.stringify(state)}`); assert( boundsNear(state.canvas?.bounds, testCase.expectedBounds), `Expected FHD surface to fit ${testCase.viewport.width}x${testCase.viewport.height}: ${JSON.stringify(state)}` ); } await page.setViewportSize(baselineViewport); await page.waitForFunction(() => { const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); return Boolean( canvas && bounds && canvas.width === 1920 && canvas.height === 1080 && Math.abs(bounds.x) <= 1 && Math.abs(bounds.y) <= 1 && Math.abs(bounds.width - 1920) <= 1 && Math.abs(bounds.height - 1080) <= 1 ); }, undefined, { timeout: 30000 }); } function boundsNear(actual, expected, tolerance = 1) { return Boolean( actual && Math.abs(actual.x - expected.x) <= tolerance && Math.abs(actual.y - expected.y) <= tolerance && Math.abs(actual.width - expected.width) <= tolerance && Math.abs(actual.height - expected.height) <= tolerance ); } async function assertHdPlusInputMapping(browser, url) { const page = await browser.newPage({ viewport: { width: 2560, height: 1600 } }); try { await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.evaluate(() => window.localStorage.clear()); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); const clickPoint = await page.evaluate(({ logicalX, logicalY }) => { const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); if (!canvas || !bounds) { return null; } return { x: bounds.x + (logicalX / canvas.width) * bounds.width, y: bounds.y + (logicalY / canvas.height) * bounds.height, canvasBounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } }; }, { logicalX: 962 * legacyUiScale, logicalY: 240 * legacyUiScale }); assert( clickPoint && boundsNear(clickPoint.canvasBounds, { x: 0, y: 80, width: 2560, height: 1440 }), `Expected a centered FHD canvas before testing above-FHD input: ${JSON.stringify(clickPoint)}` ); await page.mouse.click(clickPoint.x, clickPoint.y); await waitForStoryReady(page); const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); assert(activeScenes.includes('StoryScene'), `Expected scaled title input to enter the story: ${JSON.stringify(activeScenes)}`); } finally { await page.close(); } } async function assertLargeRosterHud(browser, url) { const expectedRosterIds = [ 'zhuge-liang', 'jiang-wei', 'wang-ping', 'zhao-yun', 'ma-dai', 'ma-chao', 'huang-quan', 'li-yan' ]; const battleUrl = new URL(url); battleUrl.searchParams.set('debugBattle', 'sixty-sixth-battle-wuzhang-final'); const page = await browser.newPage({ viewport: baselineViewport }); try { await page.goto(battleUrl.toString(), { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => { const battle = window.__HEROS_DEBUG__?.battle(); return battle?.battleId === 'sixty-sixth-battle-wuzhang-final' && battle?.mapBackgroundReady === true; }, undefined, { timeout: 90000 }); await page.evaluate((selectedSortieUnitIds) => { window.__HEROS_GAME__?.scene.getScene('BattleScene')?.scene.restart({ battleId: 'sixty-sixth-battle-wuzhang-final', selectedSortieUnitIds }); }, expectedRosterIds); await page.waitForFunction((expectedIds) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.battleId === 'sixty-sixth-battle-wuzhang-final' && battle?.mapBackgroundReady === true && JSON.stringify([...battle.deployedAllyIds].sort()) === JSON.stringify([...expectedIds].sort()) ); }, expectedRosterIds, { timeout: 90000 }); const deploymentProbe = await readLargeRosterHudProbe(page); assertLateBattleHeaderLayout(deploymentProbe, 'deployment'); assertLateBattleSidebarLayout(deploymentProbe, 'deployment', false); assertLateBattleDeploymentLayout(deploymentProbe); assertSideQuickTabsLayout(deploymentProbe, null, null, false); await page.screenshot({ path: `${screenshotDir}/rc-final-battle-deployment.png`, fullPage: true }); await assertCanvasPainted(page, 'final battle deployment'); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if (scene?.phase === 'deployment') { scene.confirmPreBattleDeployment?.(); } scene?.hideBattleEventBanner?.(); scene?.renderSituationPanel?.(); }); await page.waitForFunction(() => { const hud = window.__HEROS_DEBUG__?.battle()?.battleHud; return hud?.miniMap?.visible === true && hud?.sidebar?.bounds && hud?.deploymentPanel === null; }, undefined, { timeout: 30000 }); const situationProbe = await readLargeRosterHudProbe(page); assertLateBattleHeaderLayout(situationProbe, 'situation'); assertLateBattleSidebarLayout(situationProbe, 'situation', true); assertSideQuickTabsLayout(situationProbe, 'situation', 'situation'); await assertFinalBattleQuickTabs(page); await assertFinalBattleFhdDetailPanels(page); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene?.renderRosterPanel?.('ally', '출전 위치 확정. 행동할 장수를 선택하세요.'); }); await page.waitForFunction(() => { const roster = window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel; return roster?.tab === 'ally' && roster?.pageCount > 1; }, undefined, { timeout: 30000 }); const firstPage = await readLargeRosterHudProbe(page); assert(firstPage.roster.totalCount === expectedRosterIds.length, `Expected the exact eight-officer sortie roster: ${JSON.stringify(firstPage)}`); assert(firstPage.roster.page === 0 && firstPage.roster.pageCount >= 2, `Expected paged late-battle roster: ${JSON.stringify(firstPage)}`); assert( firstPage.roster.visibleUnitIds.length > 0 && firstPage.roster.visibleUnitIds.length <= firstPage.roster.rowsPerPage, `Expected only the current roster page to render: ${JSON.stringify(firstPage)}` ); assertLargeRosterPageLayout(firstPage, 'first'); assert(firstPage.recentActions === null, `Expected the paged roster to prioritize selectable units over the action log: ${JSON.stringify(firstPage)}`); const visibleRosterIds = new Set(firstPage.roster.visibleUnitIds); let visibleRosterCount = firstPage.roster.visibleUnitIds.length; let currentPage = firstPage; for (let pageIndex = 1; pageIndex < firstPage.roster.pageCount; pageIndex += 1) { await page.mouse.click( currentPage.roster.navigationBounds.x + currentPage.roster.navigationBounds.width - 39, currentPage.roster.navigationBounds.y + 15 ); await page.waitForFunction( (expectedPage) => window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel?.page === expectedPage, pageIndex, { timeout: 30000 } ); currentPage = await readLargeRosterHudProbe(page); visibleRosterCount += currentPage.roster.visibleUnitIds.length; currentPage.roster.visibleUnitIds.forEach((unitId) => visibleRosterIds.add(unitId)); assertLargeRosterPageLayout(currentPage, `${pageIndex + 1}`); } assert( visibleRosterCount === expectedRosterIds.length && JSON.stringify([...visibleRosterIds].sort()) === JSON.stringify([...expectedRosterIds].sort()), `Expected roster paging to expose every selected officer exactly once: ${JSON.stringify({ expectedRosterIds, visibleRosterCount, visibleRosterIds: [...visibleRosterIds] })}` ); await page.screenshot({ path: `${screenshotDir}/rc-final-battle-roster-last-page.png`, fullPage: true }); await assertCanvasPainted(page, 'final battle roster last page'); await page.evaluate(() => { window.__HEROS_GAME__?.scene.getScene('BattleScene')?.renderRosterPanel?.('enemy', '적군 전력을 확인하세요.'); }); await page.waitForFunction(() => { const roster = window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel; return roster?.tab === 'enemy' && roster?.pageCount > 1; }, undefined, { timeout: 30000 }); await page.evaluate(() => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(resolve)); })); const enemyPage = await readLargeRosterHudProbe(page); assert( enemyPage.roster.rowLayouts.some((row) => row.unitName.length >= 8), `Expected the enemy roster fixture to exercise a long unit name: ${JSON.stringify(enemyPage)}` ); assertLargeRosterPageLayout(enemyPage, 'enemy first'); await page.screenshot({ path: `${screenshotDir}/rc-final-battle-enemy-roster.png`, fullPage: true }); await assertCanvasPainted(page, 'final battle enemy roster'); await page.evaluate((selectedSortieUnitIds) => { window.__HEROS_GAME__?.scene.getScene('BattleScene')?.scene.restart({ battleId: 'sixty-sixth-battle-wuzhang-final', selectedSortieUnitIds }); }, expectedRosterIds); await page.waitForFunction((expectedIds) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.battleId === 'sixty-sixth-battle-wuzhang-final' && battle?.mapBackgroundReady === true && battle?.phase === 'deployment' && JSON.stringify([...battle.deployedAllyIds].sort()) === JSON.stringify([...expectedIds].sort()) ); }, expectedRosterIds, { timeout: 90000 }); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene?.confirmPreBattleDeployment?.(); scene?.hideBattleEventBanner?.(); scene?.renderRosterPanel?.('ally', '출전 위치 확정. 행동할 장수를 선택하세요.'); }); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel?.pageCount > 1, undefined, { timeout: 30000 }); const restartedPage = await readLargeRosterHudProbe(page); assert( restartedPage.roster.page === 0, `Expected a restarted battle to reset the roster to page one: ${JSON.stringify(restartedPage)}` ); } finally { await page.close(); } } async function assertWebglBattleQuickTabSmoke(browser, url, battleId) { const battleUrl = new URL(url); battleUrl.searchParams.set('renderer', 'webgl'); battleUrl.searchParams.set('debugBattle', battleId); const page = await browser.newPage({ viewport: baselineViewport }); const pageErrors = []; page.on('pageerror', (error) => pageErrors.push(error.message)); try { await page.addInitScript(() => window.localStorage.clear()); await page.goto(battleUrl.toString(), { waitUntil: 'domcontentloaded' }); await waitForBattleReady(page, battleId); await page.waitForFunction(() => { const battle = window.__HEROS_DEBUG__?.battle(); return battle?.phase === 'idle' && battle.battleHud?.quickTabs?.enabled === true; }, undefined, { timeout: 90000 }); const rendererProbe = await page.evaluate(() => { const game = window.__HEROS_GAME__; const scene = game?.scene.getScene('BattleScene'); return { type: game?.renderer?.type ?? null, name: game?.renderer?.constructor?.name ?? null, sceneWidth: scene?.scale.width ?? null, sceneHeight: scene?.scale.height ?? null }; }); assert( rendererProbe.type === phaserWebglRendererType && rendererProbe.sceneWidth === baselineViewport.width && rendererProbe.sceneHeight === baselineViewport.height, `Expected the ${battleId} input smoke to use the FHD WebGL renderer: ${JSON.stringify(rendererProbe)}` ); let actionableUnitPoint = await readActionableBattleUnitPoint(page); await page.mouse.click(actionableUnitPoint.x, actionableUnitPoint.y); await page.waitForFunction( (unitId) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.phase === 'moving' && battle.selectedUnitId === unitId && battle.pendingMove === null && battle.battleHud?.quickTabs?.enabled === true ); }, actionableUnitPoint.unitId, { timeout: 30000 } ); await page.keyboard.press('3'); await waitForSideQuickTabView(page, 'bond', 'bond'); const keyboardReturnState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); assert( keyboardReturnState?.phase === 'idle' && keyboardReturnState.selectedUnitId === null && keyboardReturnState.pendingMove === null && keyboardReturnState.markerCount === 0, `Expected WebGL keyboard navigation to cancel an uncommitted selection: ${JSON.stringify(keyboardReturnState)}` ); actionableUnitPoint = await readActionableBattleUnitPoint(page); await page.mouse.click(actionableUnitPoint.x, actionableUnitPoint.y); await page.waitForFunction( (unitId) => window.__HEROS_DEBUG__?.battle()?.phase === 'moving' && window.__HEROS_DEBUG__?.battle()?.selectedUnitId === unitId, actionableUnitPoint.unitId, { timeout: 30000 } ); await page.mouse.click(actionableUnitPoint.x, actionableUnitPoint.y); await page.waitForFunction( (unitId) => { const battle = window.__HEROS_DEBUG__?.battle(); return battle?.phase === 'command' && battle.selectedUnitId === unitId && battle.commandMenuVisible === true; }, actionableUnitPoint.unitId, { timeout: 30000 } ); const commandLockState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); assert( commandLockState?.pendingMove?.unitId === actionableUnitPoint.unitId && commandLockState.battleHud?.quickTabs?.enabled === false, `Expected committed movement and its command menu to keep quick tabs locked: ${JSON.stringify(commandLockState)}` ); const waitCommandPoint = await readBattleCommandControlPoint(page, 'wait'); await page.mouse.click(waitCommandPoint.x, waitCommandPoint.y); await page.waitForFunction( (completedUnitId) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.phase === 'moving' && battle.selectedUnitId && battle.selectedUnitId !== completedUnitId && battle.actedUnitIds?.includes(completedUnitId) && battle.battleHud?.quickTabs?.enabled === true ); }, actionableUnitPoint.unitId, { timeout: 90000 } ); const rosterTabPoint = await readSideQuickTabControlPoint(page, 'roster'); await page.mouse.click(rosterTabPoint.x, rosterTabPoint.y); await waitForSideQuickTabView(page, 'roster', 'roster'); const autoFocusReturnState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); assert( autoFocusReturnState?.phase === 'idle' && autoFocusReturnState.selectedUnitId === null && autoFocusReturnState.pendingMove === null && autoFocusReturnState.markerCount === 0 && autoFocusReturnState.actedUnitIds?.includes(actionableUnitPoint.unitId), `Expected a WebGL mouse tab click to leave auto-focused movement safely: ${JSON.stringify(autoFocusReturnState)}` ); const emptyTilePoint = await readEmptyBattleTilePoint(page); const transitionRevision = autoFocusReturnState.battleHud?.panelTransition?.revision; await page.mouse.move(emptyTilePoint.x, emptyTilePoint.y); await page.waitForFunction( ({ tileX, tileY }) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.pointerFeedbackVisible === true && battle.pointerFeedbackTile?.x === tileX && battle.pointerFeedbackTile?.y === tileY && battle.battleHud?.quickTabs?.activeTab === 'roster' && battle.battleHud.quickTabs.currentView === 'roster' ); }, { tileX: emptyTilePoint.tile.x, tileY: emptyTilePoint.tile.y }, { timeout: 30000 } ); const hoverState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); assert( hoverState?.battleHud?.terrainPanel === null && hoverState.battleHud.panelTransition?.revision === transitionRevision, `Expected WebGL terrain hover to preserve the roster panel: ${JSON.stringify(hoverState)}` ); await page.mouse.click(emptyTilePoint.x, emptyTilePoint.y); await waitForSideQuickTabView(page, 'situation', 'terrain-detail'); await page.keyboard.press('Escape'); await waitForSideQuickTabView(page, 'situation', 'situation'); const screenshot = await page.screenshot({ path: `${screenshotDir}/rc-webgl-${battleId}-quick-tabs.png`, fullPage: true }); assert( screenshot.byteLength >= 100_000, `Expected a visibly painted ${battleId} WebGL frame: ${JSON.stringify({ byteLength: screenshot.byteLength })}` ); assert(pageErrors.length === 0, `Unexpected ${battleId} WebGL runtime errors: ${JSON.stringify(pageErrors)}`); } finally { await page.close(); } } async function assertFinalBattleQuickTabs(page) { const initialProbe = await readLargeRosterHudProbe(page); assertSideQuickTabsLayout(initialProbe, 'situation', 'situation'); const rosterTab = initialProbe.quickTabs.tabs.find((tab) => tab.id === 'roster'); await page.mouse.move( rosterTab.bounds.x + rosterTab.bounds.width / 2, rosterTab.bounds.y + rosterTab.bounds.height / 2 ); await page.waitForFunction(() => { const quickTabs = window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs; return quickTabs?.hoveredTab === 'roster' && quickTabs.tabs?.find((tab) => tab.id === 'roster')?.visualState === 'hover'; }, undefined, { timeout: 30000 }); const hoverProbe = await readLargeRosterHudProbe(page); assert( hoverProbe.quickTabs.activeTab === 'situation' && hoverProbe.quickTabs.hoveredTab === 'roster', `Expected hover feedback without changing the active quick tab: ${JSON.stringify(hoverProbe)}` ); await page.mouse.move(initialProbe.sceneBounds.x + 20, initialProbe.sceneBounds.y + 20); await page.waitForFunction( () => window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs?.hoveredTab === null, undefined, { timeout: 30000 } ); await page.mouse.click( rosterTab.bounds.x + rosterTab.bounds.width / 2, rosterTab.bounds.y + rosterTab.bounds.height / 2 ); await waitForSideQuickTabView(page, 'roster', 'roster'); const rosterProbe = await readLargeRosterHudProbe(page); assertSideQuickTabsLayout(rosterProbe, 'roster', 'roster'); assertCompletedSidePanelTransition(rosterProbe, 'tab', 'roster'); await page.keyboard.press('1'); await waitForSideQuickTabView(page, 'situation', 'situation'); await page.keyboard.press('2'); await waitForSideQuickTabView(page, 'roster', 'roster'); const keyboardRosterProbe = await readLargeRosterHudProbe(page); assertSideQuickTabsLayout(keyboardRosterProbe, 'roster', 'roster'); assertCompletedSidePanelTransition(keyboardRosterProbe, 'tab', 'roster'); await page.keyboard.press('3'); await waitForSideQuickTabView(page, 'bond', 'bond'); const bondProbe = await readLargeRosterHudProbe(page); assertSideQuickTabsLayout(bondProbe, 'bond', 'bond'); assertCompletedSidePanelTransition(bondProbe, 'tab', 'bond'); await page.keyboard.press('4'); await waitForSideQuickTabView(page, 'threat', 'threat-overview'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.markerCount > 0, undefined, { timeout: 30000 }); const threatProbe = await readLargeRosterHudProbe(page); assertSideQuickTabsLayout(threatProbe, 'threat', 'threat-overview'); assert(threatProbe.markerCount > 0, `Expected the threat quick tab to paint map markers: ${JSON.stringify(threatProbe)}`); assertCompletedSidePanelTransition(threatProbe, 'tab', 'threat-overview'); await page.keyboard.press('1'); await waitForSideQuickTabView(page, 'situation', 'situation'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.markerCount === 0, undefined, { timeout: 30000 }); const situationProbe = await readLargeRosterHudProbe(page); assertSideQuickTabsLayout(situationProbe, 'situation', 'situation'); assert(situationProbe.markerCount === 0, `Expected another quick tab to clear threat markers: ${JSON.stringify(situationProbe)}`); assertCompletedSidePanelTransition(situationProbe, 'tab', 'situation'); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if (!scene) { return; } scene.phase = 'animating'; scene.renderSituationPanel?.('빠른 탭 입력 차단 점검'); }); await page.waitForFunction(() => { const quickTabs = window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs; return quickTabs?.visible === true && quickTabs.enabled === false && quickTabs.activeTab === 'situation'; }, undefined, { timeout: 30000 }); const disabledProbe = await readLargeRosterHudProbe(page); const disabledRosterTab = disabledProbe.quickTabs.tabs.find((tab) => tab.id === 'roster'); assert( disabledProbe.quickTabs.tabs.find((tab) => tab.id === 'situation')?.visualState === 'disabled-active' && disabledRosterTab?.visualState === 'disabled', `Expected a dimmed active tab and disabled alternatives while battle input is locked: ${JSON.stringify(disabledProbe)}` ); await page.keyboard.press('2'); await page.mouse.click( disabledRosterTab.bounds.x + disabledRosterTab.bounds.width / 2, disabledRosterTab.bounds.y + disabledRosterTab.bounds.height / 2 ); await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); const blockedProbe = await readLargeRosterHudProbe(page); assert( blockedProbe.quickTabs.activeTab === 'situation' && blockedProbe.quickTabs.currentView === 'situation', `Expected disabled mouse and keyboard quick-tab input to be ignored: ${JSON.stringify(blockedProbe)}` ); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if (scene) { scene.phase = 'idle'; } }); await page.waitForFunction( () => window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs?.enabled === true, undefined, { timeout: 30000 } ); const restoredProbe = await readLargeRosterHudProbe(page); const restoredRosterTab = restoredProbe.quickTabs.tabs.find((tab) => tab.id === 'roster'); await page.mouse.click( restoredRosterTab.bounds.x + restoredRosterTab.bounds.width / 2, restoredRosterTab.bounds.y + restoredRosterTab.bounds.height / 2 ); await waitForSideQuickTabView(page, 'roster', 'roster'); await page.keyboard.press('1'); await waitForSideQuickTabView(page, 'situation', 'situation'); } async function waitForSideQuickTabView(page, activeTab, currentView) { await page.waitForFunction( ({ expectedTab, expectedView }) => { const hud = window.__HEROS_DEBUG__?.battle()?.battleHud; return ( hud?.quickTabs?.activeTab === expectedTab && hud.quickTabs.currentView === expectedView && !hud.panelTransition?.active && hud.panelTransition?.completedRevision === hud.panelTransition?.revision ); }, { expectedTab: activeTab, expectedView: currentView }, { timeout: 30000 } ); } async function readSideQuickTabControlPoint(page, tabId) { const point = await page.evaluate((expectedTabId) => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const tab = window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs?.tabs?.find((candidate) => candidate.id === expectedTabId); const canvas = document.querySelector('canvas'); const canvasBounds = canvas?.getBoundingClientRect(); if (!scene || !tab?.bounds || !canvas || !canvasBounds) { return null; } const logicalX = tab.bounds.x + tab.bounds.width / 2; const logicalY = tab.bounds.y + tab.bounds.height / 2; return { tabId: expectedTabId, x: canvasBounds.left + logicalX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + logicalY * canvasBounds.height / scene.scale.height }; }, tabId); assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a visible ${tabId} quick-tab control: ${JSON.stringify(point)}`); return point; } async function readBattleCommandControlPoint(page, command) { const point = await page.evaluate((expectedCommand) => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const entry = scene?.commandButtons?.find((candidate) => candidate.command === expectedCommand); const canvas = document.querySelector('canvas'); const canvasBounds = canvas?.getBoundingClientRect(); const bounds = entry?.background?.getBounds?.(); if (!scene || !bounds || !canvas || !canvasBounds) { return null; } const logicalX = bounds.x + bounds.width / 2; const logicalY = bounds.y + bounds.height / 2; return { command: expectedCommand, x: canvasBounds.left + logicalX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + logicalY * canvasBounds.height / scene.scale.height }; }, command); assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a visible ${command} command control: ${JSON.stringify(point)}`); return point; } async function readActionableBattleUnitPoint(page) { const point = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const battle = window.__HEROS_DEBUG__?.battle(); const canvas = document.querySelector('canvas'); const canvasBounds = canvas?.getBoundingClientRect(); if (!scene || !battle || !canvas || !canvasBounds) { return null; } const unit = battle.units?.find((candidate) => { const view = scene.unitViews?.get(candidate.id); return candidate.faction === 'ally' && candidate.hp > 0 && !candidate.acted && view?.sprite?.visible && view.sprite.active; }); const sprite = unit ? scene.unitViews?.get(unit.id)?.sprite : null; const bounds = sprite?.getBounds?.(); if (!unit || !bounds) { return null; } const logicalX = bounds.x + bounds.width / 2; const logicalY = bounds.y + bounds.height / 2; return { unitId: unit.id, tile: { x: unit.x, y: unit.y }, x: canvasBounds.left + logicalX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + logicalY * canvasBounds.height / scene.scale.height }; }); assert( point?.unitId && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a visible actionable ally for real-input QA: ${JSON.stringify(point)}` ); return point; } async function readEmptyBattleTilePoint(page) { const point = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const battle = window.__HEROS_DEBUG__?.battle(); const canvas = document.querySelector('canvas'); const canvasBounds = canvas?.getBoundingClientRect(); const camera = battle?.camera; if (!scene || !battle || !camera || !canvas || !canvasBounds) { return null; } const occupiedTiles = new Set( (battle.units ?? []).filter((unit) => unit.hp > 0).map((unit) => `${unit.x},${unit.y}`) ); const markerTiles = new Set( (scene.markers ?? []) .filter((marker) => marker?.active && marker?.visible) .map((marker) => `${marker.getData('tileX')},${marker.getData('tileY')}`) ); const candidates = []; const maxX = Math.min(camera.mapWidth, camera.x + camera.visibleColumns); const maxY = Math.min(camera.mapHeight, camera.y + camera.visibleRows); const centerX = camera.x + camera.visibleColumns / 2; const centerY = camera.y + camera.visibleRows / 2; for (let y = camera.y; y < maxY; y += 1) { for (let x = camera.x; x < maxX; x += 1) { const key = `${x},${y}`; if (occupiedTiles.has(key) || markerTiles.has(key)) { continue; } candidates.push({ x, y, distance: Math.abs(x - centerX) + Math.abs(y - centerY) }); } } candidates.sort((left, right) => left.distance - right.distance || left.y - right.y || left.x - right.x); const tile = candidates[0]; if (!tile) { return null; } const logicalX = scene.tileCenterX(tile.x); const logicalY = scene.tileCenterY(tile.y); return { tile: { x: tile.x, y: tile.y }, x: canvasBounds.left + logicalX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + logicalY * canvasBounds.height / scene.scale.height }; }); assert( point?.tile && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected an empty visible battle tile for pointer QA: ${JSON.stringify(point)}` ); return point; } async function assertFinalBattleFhdDetailPanels(page) { const allyFixture = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const unit = scene?.debugUnitById?.('zhuge-liang'); const message = '대상 선택을 취소했습니다.\n공격, 책략, 도구, 대기 중 하나를 다시 선택하세요.\n우클릭하면 이동 취소로 돌아갑니다.'; if (!scene || !unit) { return null; } scene.renderUnitDetail?.(unit, message); return { unitId: unit.id, unitName: unit.name, messageLineCount: message.split('\n').length }; }); assert( allyFixture?.unitId === 'zhuge-liang' && allyFixture.messageLineCount === 3, `Expected the late-battle ally detail fixture to render Zhuge Liang with the real three-line command-cancel guidance: ${JSON.stringify(allyFixture)}` ); await page.waitForFunction( () => window.__HEROS_DEBUG__?.battle()?.battleHud?.unitDetailPanel?.unitId === 'zhuge-liang', undefined, { timeout: 30000 } ); const allyDetailProbe = await readLargeRosterHudProbe(page); assertUnitDetailPanelLayout(allyDetailProbe, 'zhuge-liang', 'ally'); assertCompletedSidePanelTransition(allyDetailProbe, 'detail', 'officer-detail'); const enemyFixture = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const unit = scene?.debugUnitById?.('northern-twelfth-scout-e'); if (!scene || !unit) { return null; } scene.renderUnitDetail?.(unit, scene.enemyDetailMessage?.(unit) ?? '적군 전력을 확인하세요.'); return { unitId: unit.id, unitName: unit.name }; }); assert( enemyFixture?.unitId === 'northern-twelfth-scout-e' && enemyFixture.unitName?.length >= 8, `Expected the late-battle enemy detail fixture to exercise a long officer name: ${JSON.stringify(enemyFixture)}` ); await page.waitForFunction( () => window.__HEROS_DEBUG__?.battle()?.battleHud?.unitDetailPanel?.unitId === 'northern-twelfth-scout-e', undefined, { timeout: 30000 } ); const enemyDetailProbe = await readLargeRosterHudProbe(page); assertUnitDetailPanelLayout(enemyDetailProbe, 'northern-twelfth-scout-e', 'long enemy'); await waitForBattleHudPaint(page); await page.screenshot({ path: `${screenshotDir}/rc-final-battle-unit-detail.png`, fullPage: true }); await assertCanvasPainted(page, 'final battle unit detail'); await page.mouse.click( enemyDetailProbe.unitDetailPanel.returnButtonBounds.x + enemyDetailProbe.unitDetailPanel.returnButtonBounds.width / 2, enemyDetailProbe.unitDetailPanel.returnButtonBounds.y + enemyDetailProbe.unitDetailPanel.returnButtonBounds.height / 2 ); await waitForSideQuickTabView(page, 'roster', 'roster'); await page.waitForFunction( () => window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel?.tab === 'enemy', undefined, { timeout: 30000 } ); const returnedRosterProbe = await readLargeRosterHudProbe(page); assert( returnedRosterProbe.roster?.tab === 'enemy' && returnedRosterProbe.unitDetailPanel === null && returnedRosterProbe.phase === 'idle', `Expected the shared unit-detail return to restore the matching roster: ${JSON.stringify(returnedRosterProbe)}` ); assertCompletedSidePanelTransition(returnedRosterProbe, 'back', 'roster'); const bondFixture = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if (!scene) { return null; } const expectedBondIds = scene.activeSortieBonds?.().map((bond) => bond.id) ?? []; scene.bondPage = 0; scene.renderBondPanel?.(); return { expectedBondIds }; }); assert( bondFixture?.expectedBondIds?.length > 4, `Expected the exact eight-officer final sortie to exercise paged active bonds: ${JSON.stringify(bondFixture)}` ); await page.waitForFunction( () => window.__HEROS_DEBUG__?.battle()?.battleHud?.bondPanel?.page === 0, undefined, { timeout: 30000 } ); const firstBondProbe = await readLargeRosterHudProbe(page); const bondPageCount = firstBondProbe.bondPanel?.pageCount ?? 0; assert( bondPageCount > 1 && firstBondProbe.bondPanel?.totalCount === bondFixture.expectedBondIds.length, `Expected paged final-battle bond debug state to match the active sortie bonds: ${JSON.stringify({ bondFixture, bondPanel: firstBondProbe.bondPanel })}` ); const visibleBondIds = []; for (let pageIndex = 0; pageIndex < bondPageCount; pageIndex += 1) { if (pageIndex > 0) { await page.evaluate((targetPage) => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if (!scene) { return; } scene.bondPage = targetPage; scene.renderBondPanel?.(); }, pageIndex); await page.waitForFunction( (targetPage) => window.__HEROS_DEBUG__?.battle()?.battleHud?.bondPanel?.page === targetPage, pageIndex, { timeout: 30000 } ); } const bondProbe = pageIndex === 0 ? firstBondProbe : await readLargeRosterHudProbe(page); assertBondPanelLayout(bondProbe, pageIndex, bondPageCount, bondFixture.expectedBondIds.length); visibleBondIds.push(...bondProbe.bondPanel.visibleBondIds); } assert( visibleBondIds.length === bondFixture.expectedBondIds.length && new Set(visibleBondIds).size === visibleBondIds.length && sameJsonValue([...visibleBondIds].sort(), [...bondFixture.expectedBondIds].sort()), `Expected every active final-battle bond exactly once across all pages: ${JSON.stringify({ expected: bondFixture.expectedBondIds, actual: visibleBondIds })}` ); await waitForBattleHudPaint(page); await page.screenshot({ path: `${screenshotDir}/rc-final-battle-bond-panel.png`, fullPage: true }); await assertCanvasPainted(page, 'final battle bond panel'); const expectedThreatIds = [ 'northern-twelfth-scout-e', 'northern-twelfth-camp-guard-e', 'northern-twelfth-strategist-c', 'northern-twelfth-cavalry-e' ]; const threatFixture = await page.evaluate((enemyIds) => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const enemies = enemyIds.map((unitId) => scene?.debugUnitById?.(unitId)).filter(Boolean); if (!scene || enemies.length !== enemyIds.length) { return null; } scene.renderThreatDetail?.({ x: 38, y: 96, enemies, strongestBehavior: 'aggressive' }); return { enemyIds: enemies.map((enemy) => enemy.id), enemyNames: enemies.map((enemy) => enemy.name) }; }, expectedThreatIds); assert( sameJsonValue(threatFixture?.enemyIds, expectedThreatIds) && threatFixture.enemyNames.every((name) => name.length >= 8), `Expected the final-battle threat fixture to use four real long-name enemies: ${JSON.stringify(threatFixture)}` ); await page.waitForFunction( () => window.__HEROS_DEBUG__?.battle()?.battleHud?.threatPanel?.enemyRows?.length === 4, undefined, { timeout: 30000 } ); const threatProbe = await readLargeRosterHudProbe(page); assertThreatPanelLayout(threatProbe, expectedThreatIds); assertCompletedSidePanelTransition(threatProbe, 'detail', 'threat-detail'); await waitForBattleHudPaint(page); await page.screenshot({ path: `${screenshotDir}/rc-final-battle-threat-panel.png`, fullPage: true }); await assertCanvasPainted(page, 'final battle threat panel'); await page.mouse.click( threatProbe.threatPanel.returnButtonBounds.x + threatProbe.threatPanel.returnButtonBounds.width / 2, threatProbe.threatPanel.returnButtonBounds.y + threatProbe.threatPanel.returnButtonBounds.height / 2 ); await waitForSideQuickTabView(page, 'threat', 'threat-overview'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.markerCount > 0, undefined, { timeout: 30000 }); const returnedThreatProbe = await readLargeRosterHudProbe(page); assert( returnedThreatProbe.threatPanel === null && returnedThreatProbe.markerCount > 0, `Expected the shared threat return to restore the map range overview: ${JSON.stringify(returnedThreatProbe)}` ); assertCompletedSidePanelTransition(returnedThreatProbe, 'back', 'threat-overview'); await page.evaluate(() => { window.__HEROS_GAME__?.scene.getScene('BattleScene')?.renderTerrainDetail?.(38, 96); }); await page.waitForFunction(() => { const panel = window.__HEROS_DEBUG__?.battle()?.battleHud?.terrainPanel; return panel?.tile?.x === 38 && panel?.tile?.y === 96; }, undefined, { timeout: 30000 }); const terrainProbe = await readLargeRosterHudProbe(page); assertTerrainPanelLayout(terrainProbe, 'camp'); assertCompletedSidePanelTransition(terrainProbe, 'detail', 'terrain-detail'); await waitForBattleHudPaint(page); await page.screenshot({ path: `${screenshotDir}/rc-final-battle-terrain-panel.png`, fullPage: true }); await assertCanvasPainted(page, 'final battle terrain panel'); await page.keyboard.press('Escape'); await waitForSideQuickTabView(page, 'situation', 'situation'); const returnedSituationProbe = await readLargeRosterHudProbe(page); assert( returnedSituationProbe.terrainPanel === null && returnedSituationProbe.miniMap?.visible === true, `Expected the shared terrain return to restore the situation overview: ${JSON.stringify(returnedSituationProbe)}` ); assertCompletedSidePanelTransition(returnedSituationProbe, 'back', 'situation'); } async function waitForBattleHudPaint(page) { await page.waitForFunction(() => { const transition = window.__HEROS_DEBUG__?.battle()?.battleHud?.panelTransition; return !transition || (!transition.active && transition.completedRevision === transition.revision); }, undefined, { timeout: 30000 }); await page.evaluate(() => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(resolve)); })); } async function readLargeRosterHudProbe(page) { await page.waitForFunction(() => { const transition = window.__HEROS_DEBUG__?.battle()?.battleHud?.panelTransition; return !transition || (!transition.active && transition.completedRevision === transition.revision); }, undefined, { timeout: 30000 }); return page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const battle = window.__HEROS_DEBUG__?.battle(); const hud = battle?.battleHud; return { roster: hud?.rosterPanel ?? null, recentActions: hud?.recentActions ?? null, miniMap: hud?.miniMap ?? null, header: hud?.header ?? null, sidebar: hud?.sidebar ?? null, deploymentPanel: hud?.deploymentPanel ?? null, unitDetailPanel: hud?.unitDetailPanel ?? null, bondPanel: hud?.bondPanel ?? null, threatPanel: hud?.threatPanel ?? null, terrainPanel: hud?.terrainPanel ?? null, quickTabs: hud?.quickTabs ?? null, panelTransition: hud?.panelTransition ?? null, markerCount: battle?.markerCount ?? 0, phase: battle?.phase ?? null, selectedUnitId: hud?.miniMap?.selectedUnitId ?? null, sceneBounds: scene ? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height } : null, panelBounds: scene ? { x: scene.layout.panelX, y: scene.layout.panelY, width: scene.layout.panelWidth, height: scene.layout.panelHeight } : null }; }); } function assertSideQuickTabsLayout(probe, expectedActiveTab, expectedView, expectedVisible = true) { const quickTabs = probe.quickTabs; if (!expectedVisible) { assert( quickTabs?.visible === false && quickTabs.tabs?.length === 0 && quickTabs.bounds === null, `Expected deployment to omit side quick tabs: ${JSON.stringify(probe)}` ); return; } const tabs = quickTabs?.tabs ?? []; const activeTabs = tabs.filter((tab) => tab.active); assert( quickTabs?.visible === true && quickTabs.enabled === true && quickTabs.activeTab === expectedActiveTab && quickTabs.currentView === expectedView && tabs.length === 4 && sameJsonValue(tabs.map((tab) => tab.id), ['situation', 'roster', 'bond', 'threat']) && sameJsonValue(tabs.map((tab) => tab.label), ['전황', '부대', '공명', '위협']) && sameJsonValue(tabs.map((tab) => tab.shortcut), ['1', '2', '3', '4']) && activeTabs.length === 1 && activeTabs[0].id === expectedActiveTab && activeTabs[0].visualState === 'active', `Expected one active 1-4 quick tab for ${expectedView}: ${JSON.stringify(probe)}` ); assert( isFiniteBounds(quickTabs.bounds) && boundsInside(quickTabs.bounds, probe.panelBounds) && boundsInside(quickTabs.bounds, probe.sceneBounds) && probe.header.sortieOrderBounds.y + probe.header.sortieOrderBounds.height <= quickTabs.bounds.y + 1 && quickTabs.bounds.y + quickTabs.bounds.height <= quickTabs.bodyTop + 1 && quickTabs.bodyTop === probe.sidebar.bodyTop && probe.sidebar.bounds.y >= quickTabs.bodyTop, `Expected quick tabs between the FHD header and side-panel body: ${JSON.stringify(probe)}` ); tabs.forEach((tab) => { assert( boundsInside(tab.bounds, quickTabs.bounds) && boundsInside(tab.labelBounds, tab.bounds) && boundsInside(tab.shortcutBounds, tab.bounds), `Expected quick-tab content inside its FHD button: ${JSON.stringify(tab)}` ); }); assertHorizontalBounds(tabs.map((tab) => tab.bounds), `${expectedView} quick tabs`); } function assertCompletedSidePanelTransition(probe, expectedTrigger, expectedView) { const transition = probe.panelTransition; assert( transition?.active === false && transition.completedRevision === transition.revision && transition.revision > 0 && transition.last?.kind === 'fade-slide' && transition.last.trigger === expectedTrigger && transition.last.to === expectedView && transition.last.durationMs >= 100 && transition.last.durationMs <= 240 && transition.last.offsetX > 0 && transition.last.objectCount > 0, `Expected a completed side-panel transition into ${expectedView}: ${JSON.stringify(probe)}` ); } function assertLateBattleHeaderLayout(probe, label) { const { header, panelBounds, sceneBounds } = probe; const headerBounds = [ header?.titleBounds, header?.turnBounds, header?.speedBounds, header?.objectiveBounds, header?.defeatBounds, header?.sortieOrderBounds ]; assert( isFiniteBounds(panelBounds) && isFiniteBounds(sceneBounds) && boundsInside(panelBounds, sceneBounds) && headerBounds.every((bounds) => boundsInside(bounds, panelBounds) && boundsInside(bounds, sceneBounds)), `Expected every late-battle ${label} header element inside the FHD side panel and scene: ${JSON.stringify(probe)}` ); assert( header.titleBounds.y + header.titleBounds.height <= Math.min(header.turnBounds.y, header.speedBounds.y) && header.turnBounds.x + header.turnBounds.width <= header.speedBounds.x && Math.max( header.turnBounds.y + header.turnBounds.height, header.speedBounds.y + header.speedBounds.height ) <= header.objectiveBounds.y && header.objectiveBounds.y + header.objectiveBounds.height <= header.defeatBounds.y && header.defeatBounds.y + header.defeatBounds.height <= header.sortieOrderBounds.y && header.sortieOrderBounds.y + header.sortieOrderBounds.height <= header.contentTop, `Expected the wrapped late-battle ${label} objective, defeat condition, and sortie order to remain separated: ${JSON.stringify(header)}` ); } function assertLateBattleSidebarLayout(probe, label, expectMiniMap) { const { sidebar, miniMap, header, panelBounds, sceneBounds } = probe; assert( isFiniteBounds(sidebar?.bounds) && boundsInside(sidebar.bounds, panelBounds) && boundsInside(sidebar.bounds, sceneBounds) && sidebar.contentTop === header?.contentTop && sidebar.bounds.y >= sidebar.contentTop && sidebar.bounds.y + sidebar.bounds.height <= sidebar.contentBottom, `Expected the late-battle ${label} sidebar inside its FHD content region: ${JSON.stringify(probe)}` ); assert( header.sortieOrderBounds.y + header.sortieOrderBounds.height <= sidebar.bounds.y, `Expected the late-battle ${label} sidebar below the sortie-order header: ${JSON.stringify(probe)}` ); if (!expectMiniMap) { assert(miniMap?.visible === false, `Expected late-battle ${label} to reserve the full sidebar by hiding the minimap: ${JSON.stringify(probe)}`); return; } assert( miniMap?.visible === true && boundsInside(miniMap.frameBounds, panelBounds) && boundsInside(miniMap.frameBounds, sceneBounds) && sidebar.bounds.y + sidebar.bounds.height <= miniMap.frameBounds.y, `Expected the late-battle ${label} sidebar to stay above the FHD minimap: ${JSON.stringify(probe)}` ); } function assertLateBattleDeploymentLayout(probe) { const { deploymentPanel, sidebar, panelBounds, sceneBounds } = probe; const roleBounds = deploymentPanel?.roleBounds ?? []; const deploymentBounds = [ deploymentPanel?.headerBounds, deploymentPanel?.noticeBounds, ...roleBounds, deploymentPanel?.restoreButtonBounds, deploymentPanel?.startButtonBounds ]; assert( roleBounds.length > 0 && deploymentBounds.every((bounds) => boundsInside(bounds, sidebar?.bounds) && boundsInside(bounds, panelBounds) && boundsInside(bounds, sceneBounds) ) && boundsInside(deploymentPanel?.noticeTextBounds, deploymentPanel?.noticeBounds), `Expected every late-battle deployment section inside the FHD sidebar and scene: ${JSON.stringify(probe)}` ); assert( deploymentPanel.headerBounds.y + deploymentPanel.headerBounds.height <= deploymentPanel.noticeBounds.y && deploymentPanel.noticeBounds.y + deploymentPanel.noticeBounds.height <= roleBounds[0].y && roleBounds.every((bounds, index) => index === 0 || roleBounds[index - 1].y + roleBounds[index - 1].height <= bounds.y ) && roleBounds.at(-1).y + roleBounds.at(-1).height <= deploymentPanel.restoreButtonBounds.y && deploymentPanel.restoreButtonBounds.y + deploymentPanel.restoreButtonBounds.height <= deploymentPanel.startButtonBounds.y, `Expected the late-battle deployment header, notice, roles, and actions not to overlap: ${JSON.stringify(deploymentPanel)}` ); } function assertUnitDetailPanelLayout(probe, expectedUnitId, label) { const panel = probe.unitDetailPanel; assertLateBattleSidebarLayout(probe, `${label} unit detail`, false); assertSideQuickTabsLayout(probe, 'roster', 'officer-detail'); assert( panel?.unitId === expectedUnitId && panel.summaryBounds?.length === 3 && panel.statBounds?.length === 5 && panel.effectBounds?.length > 0 && panel.effectBounds.length <= 3 && panel.equipmentBounds?.length === 3 && isFiniteBounds(panel.messageBounds), `Expected complete FHD ${label} unit-detail sections: ${JSON.stringify(probe)}` ); const allBounds = [ panel.headerBounds, panel.nameBounds, panel.levelBadgeBounds, panel.returnButtonBounds, panel.hpBounds, ...panel.summaryBounds, ...panel.statBounds, panel.statusBounds, ...panel.effectBounds, ...panel.equipmentBounds, panel.messageBounds ]; assertDetailBoundsInside(probe, allBounds, `${label} unit detail`); assert( boundsInside(panel.nameBounds, panel.headerBounds) && boundsInside(panel.levelBadgeBounds, panel.headerBounds) && boundsInside(panel.returnButtonBounds, panel.headerBounds) && panel.returnTarget === 'roster' && panel.nameBounds.x + panel.nameBounds.width <= panel.levelBadgeBounds.x + 1, `Expected the FHD ${label} unit name to remain inside the header and left of the level badge: ${JSON.stringify(panel)}` ); assertHorizontalBounds(panel.summaryBounds, `${label} unit summary cards`); assertHorizontalBounds(panel.effectBounds, `${label} unit effect cards`); assertSequentialBounds(panel.statBounds, `${label} unit stat rows`); assertSequentialBounds(panel.equipmentBounds, `${label} unit equipment rows`); const sectionBounds = [ panel.headerBounds, panel.hpBounds, boundsUnion(panel.summaryBounds), boundsUnion(panel.statBounds), panel.statusBounds, boundsUnion(panel.effectBounds), boundsUnion(panel.equipmentBounds), panel.messageBounds ]; assertSequentialBounds(sectionBounds, `${label} unit-detail sections`); } function assertBondPanelLayout(probe, expectedPage, expectedPageCount, expectedTotalCount) { const panel = probe.bondPanel; assertLateBattleSidebarLayout(probe, `bond page ${expectedPage + 1}`, false); assertSideQuickTabsLayout(probe, 'bond', 'bond'); const expectedVisibleCount = Math.min( panel?.rowsPerPage ?? 0, Math.max(0, expectedTotalCount - expectedPage * (panel?.rowsPerPage ?? 0)) ); assert( panel?.page === expectedPage && panel.pageCount === expectedPageCount && panel.totalCount === expectedTotalCount && panel.rowsPerPage > 0 && panel.visibleBondIds?.length === expectedVisibleCount && panel.rowLayouts?.length === expectedVisibleCount && new Set(panel.visibleBondIds).size === panel.visibleBondIds.length && sameJsonValue(panel.rowLayouts.map((row) => row.bondId), panel.visibleBondIds) && isFiniteBounds(panel.navigationBounds) && isFiniteBounds(panel.messageBounds), `Expected complete paged FHD bond state on page ${expectedPage + 1}: ${JSON.stringify(probe)}` ); const rowBounds = panel.rowLayouts.map((row) => row.rowBounds); const allBounds = [ panel.headerBounds, ...rowBounds, ...panel.rowLayouts.flatMap((row) => [row.nameBounds, row.levelBounds, row.titleBounds, row.gaugeBounds]), panel.navigationBounds, panel.messageBounds ]; assertDetailBoundsInside(probe, allBounds, `bond page ${expectedPage + 1}`); panel.rowLayouts.forEach((row) => { assert( boundsInside(row.nameBounds, row.rowBounds) && boundsInside(row.levelBounds, row.rowBounds) && boundsInside(row.titleBounds, row.rowBounds) && boundsInside(row.gaugeBounds, row.rowBounds) && row.nameBounds.x + row.nameBounds.width <= row.levelBounds.x + 1 && row.titleBounds.x + row.titleBounds.width <= row.gaugeBounds.x + 1, `Expected bond ${row.bondId} names, levels, titles, and gauges in separate FHD columns: ${JSON.stringify(row)}` ); }); assertSequentialBounds(rowBounds, `bond rows on page ${expectedPage + 1}`); assertSequentialBounds( [panel.headerBounds, ...rowBounds, panel.navigationBounds, panel.messageBounds], `bond sections on page ${expectedPage + 1}` ); } function assertThreatPanelLayout(probe, expectedEnemyIds) { const panel = probe.threatPanel; assertLateBattleSidebarLayout(probe, 'threat detail', true); assertSideQuickTabsLayout(probe, 'threat', 'threat-detail'); assert( panel?.tile?.x === 38 && panel.tile.y === 96 && panel.summaryRows?.length === 4 && panel.enemyRows?.length === expectedEnemyIds.length && sameJsonValue(panel.enemyRows.map((row) => row.unitId), expectedEnemyIds) && isFiniteBounds(panel.messageBounds), `Expected the four-enemy FHD threat fixture at the final-camp tile: ${JSON.stringify(probe)}` ); const summaryBounds = panel.summaryRows.map((row) => row.bounds); const enemyBounds = panel.enemyRows.map((row) => row.rowBounds); const allBounds = [ panel.headerBounds, panel.returnButtonBounds, ...summaryBounds, ...panel.summaryRows.flatMap((row) => [row.labelBounds, row.valueBounds]), ...enemyBounds, ...panel.enemyRows.flatMap((row) => [row.nameBounds, row.valueBounds]), panel.messageBounds ]; assertDetailBoundsInside(probe, allBounds, 'threat detail'); assert( panel.returnTarget === 'threat-overview' && boundsInside(panel.returnButtonBounds, panel.headerBounds), `Expected threat detail to expose an in-header range return: ${JSON.stringify(panel)}` ); assertSituationGrid(panel.summaryRows, 'threat summary', 2); panel.enemyRows.forEach((row) => { assert( boundsInside(row.nameBounds, row.rowBounds) && boundsInside(row.valueBounds, row.rowBounds) && !boundsOverlap(row.nameBounds, row.valueBounds, 2), `Expected threat names and metrics on separate readable lines for ${row.unitId}: ${JSON.stringify(row)}` ); }); assertSequentialBounds(enemyBounds, 'threat enemy rows'); assertSequentialBounds( [panel.headerBounds, boundsUnion(summaryBounds), ...enemyBounds, panel.messageBounds], 'threat detail sections' ); assert( panel.messageBounds.y + panel.messageBounds.height <= probe.miniMap.frameBounds.y + 1, `Expected the threat guidance above the FHD minimap: ${JSON.stringify(probe)}` ); } function assertTerrainPanelLayout(probe, expectedTerrain) { const panel = probe.terrainPanel; assertLateBattleSidebarLayout(probe, 'terrain detail', true); assertSideQuickTabsLayout(probe, 'situation', 'terrain-detail'); assert( panel?.tile?.x === 38 && panel.tile.y === 96 && panel.terrain === expectedTerrain && panel.summaryRows?.length === 6 && isFiniteBounds(panel.messageBounds), `Expected complete FHD ${expectedTerrain} terrain details at the final-camp tile: ${JSON.stringify(probe)}` ); const summaryBounds = panel.summaryRows.map((row) => row.bounds); const allBounds = [ panel.headerBounds, panel.returnButtonBounds, ...summaryBounds, ...panel.summaryRows.flatMap((row) => [row.labelBounds, row.valueBounds]), panel.messageBounds ]; assertDetailBoundsInside(probe, allBounds, 'terrain detail'); assert( panel.returnTarget === 'situation' && boundsInside(panel.returnButtonBounds, panel.headerBounds), `Expected terrain detail to expose an in-header situation return: ${JSON.stringify(panel)}` ); assertSituationRows(panel.summaryRows, 'terrain summary'); assertSequentialBounds([panel.headerBounds, ...summaryBounds, panel.messageBounds], 'terrain detail sections'); assert( panel.messageBounds.y + panel.messageBounds.height <= probe.miniMap.frameBounds.y + 1, `Expected the terrain guidance above the FHD minimap: ${JSON.stringify(probe)}` ); } function assertSituationRows(rows, label) { rows.forEach((row, index) => { assert( boundsInside(row.labelBounds, row.bounds) && boundsInside(row.valueBounds, row.bounds) && row.labelBounds.x + row.labelBounds.width <= row.valueBounds.x + 1, `Expected ${label} row ${index + 1} labels and values in separate FHD columns: ${JSON.stringify(row)}` ); }); assertSequentialBounds(rows.map((row) => row.bounds), `${label} rows`); } function assertSituationGrid(rows, label, columnCount) { rows.forEach((row, index) => { assert( boundsInside(row.labelBounds, row.bounds) && boundsInside(row.valueBounds, row.bounds) && row.labelBounds.x + row.labelBounds.width <= row.valueBounds.x + 1, `Expected ${label} cell ${index + 1} labels and values in separate columns: ${JSON.stringify(row)}` ); }); const rowGroups = []; for (let index = 0; index < rows.length; index += columnCount) { const rowBounds = rows.slice(index, index + columnCount).map((row) => row.bounds); assertHorizontalBounds(rowBounds, `${label} row ${rowGroups.length + 1}`); rowGroups.push(boundsUnion(rowBounds)); } assertSequentialBounds(rowGroups, `${label} rows`); } function assertDetailBoundsInside(probe, boundsList, label) { assert( isFiniteBounds(probe.sidebar?.bounds) && isFiniteBounds(probe.panelBounds) && isFiniteBounds(probe.sceneBounds) && boundsList.length > 0 && boundsList.every((bounds) => boundsInside(bounds, probe.sidebar.bounds) && boundsInside(bounds, probe.panelBounds) && boundsInside(bounds, probe.sceneBounds) ), `Expected every ${label} bound inside the sidebar, panel, and FHD scene: ${JSON.stringify({ boundsList, probe })}` ); } function assertSequentialBounds(boundsList, label) { assert( boundsList.length > 0 && boundsList.every(isFiniteBounds) && boundsList.every((bounds, index) => index === 0 || boundsList[index - 1].y + boundsList[index - 1].height <= bounds.y + 1 ), `Expected non-overlapping sequential ${label}: ${JSON.stringify(boundsList)}` ); } function assertHorizontalBounds(boundsList, label) { const ordered = [...boundsList].sort((left, right) => left.x - right.x); assert( ordered.length > 0 && ordered.every(isFiniteBounds) && ordered.every((bounds, index) => index === 0 || ordered[index - 1].x + ordered[index - 1].width <= bounds.x + 1 ), `Expected non-overlapping horizontal ${label}: ${JSON.stringify(ordered)}` ); } function boundsUnion(boundsList) { const left = Math.min(...boundsList.map((bounds) => bounds.x)); const top = Math.min(...boundsList.map((bounds) => bounds.y)); const right = Math.max(...boundsList.map((bounds) => bounds.x + bounds.width)); const bottom = Math.max(...boundsList.map((bounds) => bounds.y + bounds.height)); return { x: left, y: top, width: right - left, height: bottom - top }; } function assertLargeRosterPageLayout(probe, label) { const { roster, panelBounds } = probe; assert(boundsInside(roster.listBounds, panelBounds), `Expected roster rows on page ${label} inside the side panel: ${JSON.stringify(probe)}`); assert(boundsInside(roster.navigationBounds, panelBounds), `Expected roster navigation on page ${label} inside the side panel: ${JSON.stringify(probe)}`); assert(boundsInside(roster.messageBounds, panelBounds), `Expected roster guidance on page ${label} inside the side panel: ${JSON.stringify(probe)}`); assert( roster.rowLayouts.length === roster.visibleUnitIds.length && roster.rowLayouts.every((row) => boundsInside(row.rowBounds, roster.listBounds) && boundsInside(row.nameBounds, row.rowBounds) && boundsInside(row.metaBounds, row.rowBounds) && boundsInside(row.hpBounds, row.rowBounds) && boundsInside(row.gaugeBounds, row.rowBounds) && row.nameBounds.x + row.nameBounds.width <= row.hpBounds.x && row.metaBounds.x + row.metaBounds.width <= row.gaugeBounds.x ), `Expected roster page ${label} names, classes, HP, and gauges to remain in separate columns: ${JSON.stringify(probe)}` ); assert( roster.listBounds.y + roster.listBounds.height <= roster.navigationBounds.y && roster.navigationBounds.y + roster.navigationBounds.height <= roster.messageBounds.y && roster.gapToMiniMap >= 6, `Expected roster page ${label} content to stay vertically separated: ${JSON.stringify(probe)}` ); } 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 clickBattleDeploymentStart(page); 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 assertLiveSortieOrderHud(page, { battleId, orderId, context }) { const probe = await page.evaluate(({ expectedBattleId, expectedOrderId }) => { const state = window.__HEROS_DEBUG__?.battle(); const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const operationOrder = state?.sortieOperationOrder; const rawHud = operationOrder?.live ?? operationOrder?.hud; const criteria = rawHud?.criteria ?? rawHud?.progress ?? []; return { battleId: state?.battleId ?? null, phase: state?.phase ?? null, battleOutcome: state?.battleOutcome ?? null, resultVisible: state?.resultVisible ?? false, selectedId: operationOrder?.selectedId ?? null, result: operationOrder?.result ?? null, sceneBounds: scene ? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height } : null, panelBounds: scene?.layout ? { x: scene.layout.panelX, y: scene.layout.panelY, width: scene.layout.panelWidth, height: scene.layout.panelHeight } : null, hud: rawHud ? { visible: rawHud.visible, bounds: rawHud.bounds ?? null, orderId: rawHud.orderId ?? null, label: rawHud.label ?? '', victoryLabel: rawHud.victoryLabel ?? '', victoryTone: rawHud.victoryTone ?? rawHud.victoryStatus ?? '', status: rawHud.status ?? '', tone: rawHud.tone ?? rawHud.status ?? '', projectedScore: rawHud.projectedScore, commandReady: rawHud.commandReady, commandUnlocked: rawHud.commandUnlocked, commandUnlockTurn: rawHud.commandUnlockTurn, commandUsed: rawHud.commandUsed, commandSource: rawHud.commandSource, commandActorId: rawHud.commandActorId, commandBounds: rawHud.commandBounds ?? null, command: rawHud.command ? { ...rawHud.command } : null, criteria: criteria.map((entry) => ({ id: entry?.id ?? null, label: entry?.label ?? '', value: entry?.value, tone: entry?.tone ?? entry?.status ?? '', achieved: entry?.achieved, bounds: entry?.bounds ?? null })), stampedIds: Array.isArray(rawHud.stampedIds) ? [...rawHud.stampedIds] : rawHud.stampedIds, animationCount: rawHud.animationCount } : null, expectedBattleId, expectedOrderId, battleLog: Array.isArray(state?.battleLog) ? [...state.battleLog] : [] }; }, { expectedBattleId: battleId, expectedOrderId: orderId }); const hud = probe.hud; const requiredCriteriaIds = ['elite-grade', 'elite-survival']; const criterionIds = hud?.criteria?.map((entry) => entry.id) ?? []; assert( probe.battleId === battleId && probe.phase !== 'resolved' && probe.battleOutcome === null && probe.resultVisible === false && probe.selectedId === orderId && probe.result === null, `Expected ${context} to expose the selected ${orderId} order before its result: ${JSON.stringify(probe)}` ); assert( hud?.visible === true && hud.orderId === orderId && typeof hud.label === 'string' && hud.label.includes('정예') && typeof hud.victoryLabel === 'string' && hud.victoryLabel.length > 0 && typeof hud.victoryTone === 'string' && hud.victoryTone.length > 0 && typeof hud.status === 'string' && hud.status.length > 0 && typeof hud.tone === 'string' && hud.tone.length > 0 && Number.isFinite(hud.projectedScore) && typeof hud.commandReady === 'boolean' && typeof hud.commandUnlocked === 'boolean' && typeof hud.commandUsed === 'boolean' && (hud.commandUnlockTurn === null || Number.isInteger(hud.commandUnlockTurn)) && (hud.commandSource === null || hud.commandSource === 'sortie-order' || hud.commandSource === 'initiative') && (hud.commandActorId === null || typeof hud.commandActorId === 'string') && (hud.commandBounds === null || (isFiniteBounds(hud.commandBounds) && boundsInside(hud.commandBounds, hud.bounds))), `Expected ${context} to show a fully labelled live ${orderId} HUD with a finite projected score: ${JSON.stringify(probe)}` ); assert( isFiniteBounds(probe.sceneBounds) && isFiniteBounds(probe.panelBounds) && isFiniteBounds(hud.bounds) && boundsInside(hud.bounds, probe.sceneBounds) && boundsInside(hud.bounds, probe.panelBounds) && requiredCriteriaIds.every((id) => criterionIds.includes(id)) && hud.criteria.length >= requiredCriteriaIds.length && hud.criteria.every((entry) => ( typeof entry.id === 'string' && entry.id.length > 0 && typeof entry.label === 'string' && entry.label.length > 0 && Object.prototype.hasOwnProperty.call(entry, 'value') && typeof entry.tone === 'string' && entry.tone.length > 0 && typeof entry.achieved === 'boolean' && isFiniteBounds(entry.bounds) && boundsInside(entry.bounds, hud.bounds) )), `Expected ${context} live order criteria and bounds to remain inside the visible HUD panel: ${JSON.stringify(probe)}` ); assert( Array.isArray(hud.stampedIds) && hud.stampedIds.length === 0 && hud.animationCount === 0, `Expected ${context} live order HUD to start without stamped criteria or animations: ${JSON.stringify(probe)}` ); return probe; } async function activateSortieOrderCommand(page, role) { await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene?.hideBattleEventBanner?.(); }); const openPoint = await readBattleOrderCommandControlPoint(page, 'open'); await page.mouse.click(openPoint.x, openPoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder?.commandPanelOpen === true, undefined, { timeout: 30000 } ); const panelProbe = await page.evaluate(() => { const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder; const cards = Object.fromEntries(Object.entries(order?.commandCards ?? {}).map(([cardRole, rawCard]) => [ cardRole, rawCard && typeof rawCard === 'object' && 'bounds' in rawCard ? { ...rawCard, bounds: rawCard.bounds } : { role: cardRole, bounds: rawCard } ])); return { open: order?.commandPanelOpen ?? false, panelBounds: order?.commandPanelBounds ?? null, cards }; }); assert( panelProbe.open === true && isFiniteBounds(panelProbe.panelBounds) && ['front', 'flank', 'support'].every((cardRole) => ( isFiniteBounds(panelProbe.cards?.[cardRole]?.bounds) && boundsInside(panelProbe.cards[cardRole].bounds, panelProbe.panelBounds) )) && panelProbe.cards?.[role]?.enabled !== false, `Expected the ready order command panel to expose three bounded role choices: ${JSON.stringify(panelProbe)}` ); await page.screenshot({ path: `${screenshotDir}/rc-first-battle-order-command.png`, fullPage: true }); await assertCanvasPainted(page, 'first battle order command'); const rolePoint = await readBattleOrderCommandControlPoint(page, 'card', role); await page.mouse.click(rolePoint.x, rolePoint.y); await page.waitForFunction( (expectedRole) => { const state = window.__HEROS_DEBUG__?.battle(); const order = state?.sortieOperationOrder; return ( order?.commandPanelOpen === false && order?.live?.commandUsed === true && order?.live?.command?.role === expectedRole && state?.tacticalInitiative?.cutIn?.active === true ); }, role, { timeout: 30000 } ); await page.waitForTimeout(180); const activeCutInProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const state = window.__HEROS_DEBUG__?.battle(); return { cutIn: state?.tacticalInitiative?.cutIn ?? null, command: state?.sortieOperationOrder?.live?.command ?? null, sceneBounds: scene ? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height } : null }; }); assert( activeCutInProbe.cutIn?.active === true && activeCutInProbe.cutIn.count === 1 && activeCutInProbe.cutIn.last?.source === 'sortie-order' && activeCutInProbe.cutIn.last?.role === role && typeof activeCutInProbe.cutIn.last?.actorId === 'string' && activeCutInProbe.cutIn.last.actorId.length > 0 && activeCutInProbe.cutIn.last.actorId === activeCutInProbe.command?.actorId && activeCutInProbe.cutIn.last?.label === '\uC7AC\uC815\uBE44' && isFiniteBounds(activeCutInProbe.cutIn.bounds) && isFiniteBounds(activeCutInProbe.cutIn.last?.bounds) && isFiniteBounds(activeCutInProbe.sceneBounds) && boundsInside(activeCutInProbe.cutIn.bounds, activeCutInProbe.sceneBounds) && boundsInside(activeCutInProbe.cutIn.last.bounds, activeCutInProbe.sceneBounds), `Expected the selected order command to show one bounded in-scene cut-in with its source, role, actor, and label: ${JSON.stringify(activeCutInProbe)}` ); await page.screenshot({ path: `${screenshotDir}/rc-first-battle-order-command-cutin.png`, fullPage: true }); await assertCanvasPainted(page, 'first battle order command cut-in'); await page.waitForFunction( (expectedRole) => { const state = window.__HEROS_DEBUG__?.battle(); const order = state?.sortieOperationOrder; const cutIn = state?.tacticalInitiative?.cutIn; return ( cutIn?.active === false && cutIn.count === 1 && cutIn.last?.source === 'sortie-order' && cutIn.last?.role === expectedRole && order?.live?.command?.role === expectedRole && order.live.command.effectPending === false && order.live.command.effectValue > 0 ); }, role, { timeout: 30000 } ); const settledProbe = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return { order: state?.sortieOperationOrder ?? null, cutIn: state?.tacticalInitiative?.cutIn ?? null }; }); assert( settledProbe.cutIn?.active === false && settledProbe.cutIn.count === 1 && settledProbe.cutIn.last?.source === 'sortie-order' && settledProbe.cutIn.last?.role === role && settledProbe.cutIn.last?.actorId === settledProbe.order?.live?.command?.actorId && settledProbe.cutIn.last?.label === '\uC7AC\uC815\uBE44' && isFiniteBounds(settledProbe.cutIn.last?.bounds) && settledProbe.order?.live?.command?.effectPending === false && settledProbe.order?.live?.command?.effectValue > 0, `Expected the cut-in to finish once while retaining its last payload before resolving the support effect: ${JSON.stringify(settledProbe)}` ); return settledProbe.order; } async function readBattleOrderCommandControlPoint(page, control, role) { const probe = await page.evaluate(({ control, role }) => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const canvas = document.querySelector('canvas'); const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder; const rawCard = role ? order?.commandCards?.[role] : undefined; const cardBounds = rawCard && typeof rawCard === 'object' && 'bounds' in rawCard ? rawCard.bounds : rawCard; const bounds = control === 'open' ? order?.live?.commandBounds : cardBounds; if (!scene || !canvas || !bounds) { return { ready: false, control, role: role ?? null, bounds: bounds ?? null, order }; } const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, control, role: role ?? null, bounds, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, { control, role }); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), `Expected a visible battle order-command ${control} control: ${JSON.stringify(probe)}` ); return probe; } 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 waitForCampVisualFrame(page, expectedPage) { await page.waitForFunction( (pageNumber) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === pageNumber, expectedPage, { timeout: 15000 } ); await page.evaluate(() => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(resolve)); })); await page.waitForFunction(() => { const canvas = document.querySelector('canvas'); const context = canvas?.getContext('2d', { willReadFrequently: true }); if (!canvas || !context) { return false; } const colorBuckets = new Set(); let litSamples = 0; for (let y = 110; y <= 560; y += 30) { for (let x = 400; x <= 1200; x += 30) { const [r, g, b, a] = context.getImageData(x, y, 1, 1).data; if (a > 8 && r + g + b > 54) { litSamples += 1; } colorBuckets.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 5}`); } } return litSamples >= 18 && colorBuckets.size >= 12; }, undefined, { timeout: 15000 }); } async function captureStableCampScreenshot(page, path, expectedPage, label) { await waitForCampVisualFrame(page, expectedPage); const capture = await page.evaluate(() => new Promise((resolve) => { const game = window.__HEROS_GAME__; const canvas = document.querySelector('canvas'); if (!game?.loop || !game.renderer || !canvas) { resolve({ loopAvailable: false, dataUrl: null }); return; } game.renderer.once('postrender', () => { game.loop.sleep(); resolve({ loopAvailable: true, dataUrl: canvas.toDataURL('image/png') }); }); })); try { assert( capture.dataUrl?.startsWith('data:image/png;base64,'), `Expected a complete canvas capture for ${label}: ${JSON.stringify({ loopAvailable: capture.loopAvailable, hasDataUrl: Boolean(capture.dataUrl) })}` ); writeFileSync(path, Buffer.from(capture.dataUrl.split(',')[1], 'base64')); await assertCanvasPainted(page, label); } finally { if (capture.loopAvailable) { await page.evaluate(() => new Promise((resolve) => { window.__HEROS_GAME__?.loop?.wake(); requestAnimationFrame(() => requestAnimationFrame(resolve)); })); } } } async function assertFinalCampRosterPagination(page) { const initialState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const initialRoster = initialState?.campRoster; const initialSelectedUnitId = initialState?.selectedUnitId ?? null; const initialSave = await readCampaignSave(page); const expectedUnitIds = (initialState?.campaign?.roster ?? []).map((unit) => unit.id); assert( initialRoster?.page === 0 && initialRoster.pageCount === 4 && initialRoster.rowsPerPage === 6 && initialRoster.totalCount === 23 && expectedUnitIds.length === 23 && new Set(expectedUnitIds).size === 23 && expectedUnitIds.includes(initialSelectedUnitId), `Expected the final camp roster to open as four six-row pages for 23 unique officers: ${JSON.stringify({ campRoster: initialRoster, expectedUnitIds })}` ); const visitedUnitIds = []; let verificationError; try { for (let expectedPage = 0; expectedPage < initialRoster.pageCount; expectedPage += 1) { const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const roster = state?.campRoster; assertCampRosterPageLayout(roster, expectedPage, initialRoster.pageCount, initialSelectedUnitId, state?.selectedUnitId); const expectedVisibleCount = expectedPage === initialRoster.pageCount - 1 ? initialRoster.totalCount - initialRoster.rowsPerPage * (initialRoster.pageCount - 1) : initialRoster.rowsPerPage; assert( roster.visibleUnitIds.length === expectedVisibleCount && roster.rowBounds.length === expectedVisibleCount && sameJsonValue(roster.rowBounds.map((row) => row.unitId), roster.visibleUnitIds), `Expected final camp roster page ${expectedPage + 1} to expose ${expectedVisibleCount} matching rows: ${JSON.stringify(roster)}` ); roster.visibleUnitIds.forEach((unitId) => { assert( expectedUnitIds.includes(unitId) && !visitedUnitIds.includes(unitId), `Expected each final camp officer to appear on exactly one roster page: ${JSON.stringify({ expectedPage, unitId, visitedUnitIds, expectedUnitIds })}` ); visitedUnitIds.push(unitId); }); if (expectedPage === 1) { const selectionTargetUnitId = roster.visibleUnitIds[0]; assert( selectionTargetUnitId && selectionTargetUnitId !== initialSelectedUnitId, `Expected the second roster page to expose a selectable officer other than the initial detail: ${JSON.stringify(roster)}` ); const selectionPoint = await readCampRosterRowPoint(page, selectionTargetUnitId); await page.mouse.click(selectionPoint.x, selectionPoint.y); await page.waitForFunction( ({ pageNumber, unitId }) => { const state = window.__HEROS_DEBUG__?.camp(); return state?.campRoster?.page === pageNumber && state?.selectedUnitId === unitId; }, { pageNumber: expectedPage, unitId: selectionTargetUnitId }, { timeout: 30000 } ); await navigateCampRosterPage(page, 'previous', 0, selectionTargetUnitId); const restorePoint = await readCampRosterRowPoint(page, initialSelectedUnitId); await page.mouse.click(restorePoint.x, restorePoint.y); await page.waitForFunction( (unitId) => window.__HEROS_DEBUG__?.camp()?.selectedUnitId === unitId, initialSelectedUnitId, { timeout: 30000 } ); await navigateCampRosterPage(page, 'next', expectedPage, initialSelectedUnitId); } if (expectedPage === initialRoster.pageCount - 1) { assert( roster.visibleUnitIds.length === 5 && roster.previousEnabled === true && roster.nextEnabled === false, `Expected the final roster page to contain five officers with only previous navigation enabled: ${JSON.stringify(roster)}` ); await captureStableCampScreenshot( page, `${screenshotDir}/rc-final-camp-roster-last-page.png`, expectedPage, 'final camp roster last page' ); continue; } await navigateCampRosterPage(page, 'next', expectedPage + 1, initialSelectedUnitId); } assert( visitedUnitIds.length === 23 && new Set(visitedUnitIds).size === 23 && sameJsonValue([...visitedUnitIds].sort(), [...expectedUnitIds].sort()), `Expected pagination to expose all 23 final-camp officers exactly once: ${JSON.stringify({ visitedUnitIds, expectedUnitIds })}` ); for (let expectedPage = initialRoster.pageCount - 2; expectedPage >= 0; expectedPage -= 1) { await navigateCampRosterPage(page, 'previous', expectedPage, initialSelectedUnitId); } } catch (error) { verificationError = error; } finally { const restoreState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const currentPage = restoreState?.campRoster?.page ?? 0; const restoreSelectedUnitId = restoreState?.selectedUnitId ?? null; for (let pageNumber = currentPage; pageNumber > 0; pageNumber -= 1) { await navigateCampRosterPage(page, 'previous', pageNumber - 1, restoreSelectedUnitId); } } if (verificationError) { throw verificationError; } const restoredState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const restoredSave = await readCampaignSave(page); assert( restoredState?.campRoster?.page === initialRoster.page && restoredState?.selectedUnitId === initialSelectedUnitId && sameJsonValue(restoredSave, initialSave), `Expected the final-camp roster fixture to restore its page, selected detail, and saves: ${JSON.stringify({ initialPage: initialRoster.page, restoredPage: restoredState?.campRoster?.page, initialSelectedUnitId, restoredSelectedUnitId: restoredState?.selectedUnitId, saveUnchanged: sameJsonValue(restoredSave, initialSave) })}` ); } function assertCampRosterPageLayout(roster, expectedPage, pageCount, expectedSelectedUnitId, actualSelectedUnitId) { assert( roster?.page === expectedPage && roster.pageCount === pageCount && roster.rowsPerPage === 6 && roster.totalCount === 23 && actualSelectedUnitId === expectedSelectedUnitId, `Expected stable final-camp roster state on page ${expectedPage + 1}: ${JSON.stringify({ roster, expectedSelectedUnitId, actualSelectedUnitId })}` ); assert( roster.previousEnabled === (expectedPage > 0) && roster.nextEnabled === (expectedPage < pageCount - 1), `Expected correct final-camp roster navigation state on page ${expectedPage + 1}: ${JSON.stringify(roster)}` ); const listBounds = roster.listBounds; assert(isPositiveBounds(listBounds), `Expected final-camp roster list bounds on page ${expectedPage + 1}: ${JSON.stringify(roster)}`); assert( isBoundsInside(listBounds, { x: 0, y: 0, width: 1920, height: 1080 }), `Expected final-camp roster list inside the 1920x1080 canvas: ${JSON.stringify(roster)}` ); assert( isPositiveBounds(roster.navigationBounds?.container) && isPositiveBounds(roster.navigationBounds?.previous) && isPositiveBounds(roster.navigationBounds?.label) && isPositiveBounds(roster.navigationBounds?.next) && isBoundsInside(roster.navigationBounds.container, { x: 0, y: 0, width: 1920, height: 1080 }), `Expected complete final-camp roster navigation bounds inside the FHD canvas: ${JSON.stringify(roster)}` ); assert( isBoundsInside(roster.navigationBounds.previous, roster.navigationBounds.container) && isBoundsInside(roster.navigationBounds.label, roster.navigationBounds.container) && isBoundsInside(roster.navigationBounds.next, roster.navigationBounds.container) && listBounds.y + listBounds.height <= roster.navigationBounds.container.y, `Expected final-camp roster controls to stay inside their navigation bar and below every row: ${JSON.stringify(roster)}` ); roster.rowBounds.forEach((row, rowIndex) => { assert( row.unitId === roster.visibleUnitIds[rowIndex] && isPositiveBounds(row) && isBoundsInside(row, listBounds) && isBoundsInside(row, { x: 0, y: 0, width: 1920, height: 1080 }) && row.y + row.height <= roster.navigationBounds.container.y, `Expected final-camp roster row ${rowIndex + 1} to match its visible officer and remain inside the list and FHD canvas: ${JSON.stringify({ row, listBounds, visibleUnitIds: roster.visibleUnitIds })}` ); }); for (let leftIndex = 0; leftIndex < roster.rowBounds.length; leftIndex += 1) { for (let rightIndex = leftIndex + 1; rightIndex < roster.rowBounds.length; rightIndex += 1) { assert( !boundsOverlap(roster.rowBounds[leftIndex], roster.rowBounds[rightIndex]), `Expected final-camp roster rows on page ${expectedPage + 1} not to overlap: ${JSON.stringify({ left: roster.rowBounds[leftIndex], right: roster.rowBounds[rightIndex] })}` ); } } } async function readCampRosterNavigationPoint(page, direction) { const probe = await page.evaluate((navigationDirection) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const canvas = document.querySelector('canvas'); const roster = window.__HEROS_DEBUG__?.camp()?.campRoster; const bounds = roster?.navigationBounds?.[navigationDirection]; const enabled = navigationDirection === 'previous' ? roster?.previousEnabled : roster?.nextEnabled; if (!scene || !canvas || !bounds || !enabled) { return { ready: false, direction: navigationDirection, enabled: enabled ?? false, bounds: bounds ?? null, roster }; } const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, direction: navigationDirection, enabled, bounds, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, direction); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), `Expected an enabled final-camp roster ${direction} control: ${JSON.stringify(probe)}` ); return probe; } async function navigateCampRosterPage(page, direction, expectedPage, expectedSelectedUnitId) { const maxAttempts = 3; const attemptTimeout = 4000; let lastError; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const before = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.camp(); return { page: state?.campRoster?.page ?? null, selectedUnitId: state?.selectedUnitId ?? null, campRoster: state?.campRoster ?? null }; }); if (before.page === expectedPage && before.selectedUnitId === expectedSelectedUnitId) { return before; } if (before.selectedUnitId !== expectedSelectedUnitId) { throw new Error( `Final-camp roster ${direction} navigation changed the selected detail before attempt ${attempt}: ${JSON.stringify({ expectedPage, expectedSelectedUnitId, before })}` ); } try { const point = await readCampRosterNavigationPoint(page, direction); await page.mouse.click(point.x, point.y); await page.waitForFunction( ({ pageNumber, selectedUnitId }) => { const state = window.__HEROS_DEBUG__?.camp(); return state?.campRoster?.page === pageNumber && state?.selectedUnitId === selectedUnitId; }, { pageNumber: expectedPage, selectedUnitId: expectedSelectedUnitId }, { timeout: attemptTimeout } ); return await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.camp(); return { page: state?.campRoster?.page ?? null, selectedUnitId: state?.selectedUnitId ?? null, campRoster: state?.campRoster ?? null }; }); } catch (error) { lastError = error; if (attempt < maxAttempts) { await page.waitForTimeout(120); } } } const finalState = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.camp(); return { page: state?.campRoster?.page ?? null, selectedUnitId: state?.selectedUnitId ?? null, campRoster: state?.campRoster ?? null }; }); const lastErrorSummary = lastError instanceof Error ? `${lastError.name}: ${lastError.message}` : String(lastError ?? 'unknown input failure'); throw new Error( `Failed final-camp roster ${direction} navigation to page ${expectedPage + 1} after ${maxAttempts} attempts: ${JSON.stringify({ expectedPage, expectedSelectedUnitId, finalState, lastError: lastErrorSummary })}` ); } async function readCampRosterRowPoint(page, unitId) { const probe = await page.evaluate((targetUnitId) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const canvas = document.querySelector('canvas'); const roster = window.__HEROS_DEBUG__?.camp()?.campRoster; const bounds = roster?.rowBounds?.find((row) => row.unitId === targetUnitId); if (!scene || !canvas || !bounds) { return { ready: false, unitId: targetUnitId, bounds: bounds ?? null, roster }; } const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, unitId: targetUnitId, bounds, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, unitId); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), `Expected a visible final-camp roster row for ${unitId}: ${JSON.stringify(probe)}` ); return probe; } async function selectCampRosterUnit(page, unitId) { const initial = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.campRoster ?? null); const maxAttempts = Math.max(1, initial?.pageCount ?? 1); for (let attempt = 0; attempt < maxAttempts; attempt += 1) { const state = await page.evaluate(() => { const camp = window.__HEROS_DEBUG__?.camp(); return { selectedUnitId: camp?.selectedUnitId ?? null, roster: camp?.campRoster ?? null }; }); if (state.roster?.visibleUnitIds?.includes(unitId)) { const point = await readCampRosterRowPoint(page, unitId); await page.mouse.click(point.x, point.y); await page.waitForFunction( (targetUnitId) => window.__HEROS_DEBUG__?.camp()?.selectedUnitId === targetUnitId, unitId, { timeout: 30000 } ); return page.evaluate(() => window.__HEROS_DEBUG__?.camp()); } if (!state.roster?.nextEnabled || !state.selectedUnitId) { break; } await navigateCampRosterPage(page, 'next', state.roster.page + 1, state.selectedUnitId); } const finalState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); throw new Error( 'Could not find camp roster unit ' + unitId + ' across ' + maxAttempts + ' page(s): ' + JSON.stringify(finalState?.campRoster) ); } function isPositiveBounds(bounds) { return Boolean( bounds && Number.isFinite(bounds.x) && Number.isFinite(bounds.y) && Number.isFinite(bounds.width) && Number.isFinite(bounds.height) && bounds.width > 0 && bounds.height > 0 ); } function isBoundsInside(inner, outer, tolerance = 0.01) { return ( isPositiveBounds(inner) && isPositiveBounds(outer) && inner.x >= outer.x - tolerance && inner.y >= outer.y - tolerance && inner.x + inner.width <= outer.x + outer.width + tolerance && inner.y + inner.height <= outer.y + outer.height + tolerance ); } function boundsOverlap(left, right, tolerance = 0.01) { return ( left.x < right.x + right.width - tolerance && left.x + left.width > right.x + tolerance && left.y < right.y + right.height - tolerance && left.y + left.height > right.y + tolerance ); } 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 clickLegacyUi(page, 1116, 656); await page.waitForFunction((step) => { const camp = window.__HEROS_DEBUG__?.camp?.(); return camp?.sortieVisible === true && camp?.sortiePrepStep === step; }, expectedStep, { timeout: 30000 }); if (expectedStep === 'formation') { const operationOrder = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()?.sortieOperationOrder); if (operationOrder?.required && !operationOrder.complete) { assert( operationOrder.open === true && operationOrder.cards?.length === 3 && operationOrder.cards.every((card) => card.cardBounds), `Expected formation entry to open three explicit sortie-order choices: ${JSON.stringify(operationOrder)}` ); const eliteCardPoint = await readSortieOrderControlPoint(page, 'card', 'elite'); await page.mouse.click(eliteCardPoint.x, eliteCardPoint.y); await page.waitForFunction(() => { const order = window.__HEROS_DEBUG__?.camp?.()?.sortieOperationOrder; return order?.selectedId === 'elite' && order?.complete === true; }, undefined, { timeout: 30000 }); const closePoint = await readSortieOrderControlPoint(page, 'close'); await page.mouse.click(closePoint.x, closePoint.y); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp?.()?.sortieOperationOrder?.open === false, undefined, { 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 readSortieOrderControlPoint(page, control, orderId) { const probe = await page.evaluate(({ control, orderId }) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const state = window.__HEROS_DEBUG__?.camp?.(); const canvas = document.querySelector('canvas'); const order = state?.sortieOperationOrder; const card = order?.cards?.find((candidate) => candidate.id === orderId); const bounds = control === 'toggle' ? order?.toggleButtonBounds : control === 'close' ? order?.closeButtonBounds : card?.cardBounds; if (!scene || !canvas || !bounds) { return { ready: false, control, orderId: orderId ?? null, bounds: bounds ?? null, order }; } const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, control, orderId: orderId ?? null, bounds, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, { control, orderId }); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), `Expected a visible sortie-order ${control} control: ${JSON.stringify(probe)}` ); return probe; } 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 readBattleSortieOrderControlPoint(page, control) { const probe = await page.evaluate((control) => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const canvas = document.querySelector('canvas'); const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder; const bounds = control === 'toggle' ? order?.toggleButtonBounds : order?.closeButtonBounds; if (!scene || !canvas || !bounds) { return { ready: false, control, bounds: bounds ?? null, order }; } const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, control, bounds, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, control); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), `Expected a visible battle sortie-order ${control} control: ${JSON.stringify(probe)}` ); return probe; } async function readCampReportFormationEvaluationControlPoint(page, control, presetId) { const probe = await page.evaluate(({ control, presetId }) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const evaluation = window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation; 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 camp-report evaluation ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}` ); return probe; } async function readCampReportFormationHistoryControlPoint(page, control) { const probe = await page.evaluate((control) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const history = window.__HEROS_DEBUG__?.camp()?.reportFormationHistory; const canvas = document.querySelector('canvas'); const bounds = control === 'toggle' ? history?.toggleButtonBounds : control === 'close' ? history?.closeButtonBounds : control === 'best' ? history?.bestButtonBounds : control === 'loadBest' ? history?.loadBestButtonBounds : history?.undoButtonBounds; if (!scene || !canvas || !bounds) { return { ready: false, control, historyOpen: history?.open ?? false, loadUndoAvailable: history?.loadUndoAvailable ?? 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, historyOpen: history?.open ?? false, loadUndoAvailable: history?.loadUndoAvailable ?? false, bounds, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, control); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), `Expected a visible Phaser camp formation-history ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}` ); return probe; } async function readCampStaticTextControlPoint(page, label) { const probe = await page.evaluate((targetLabel) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const canvas = document.querySelector('canvas'); const target = scene?.children?.list ?.filter( (object) => object?.type === 'Text' && object.text === targetLabel && object.active && object.visible && object.input?.enabled ) .sort((left, right) => left.y - right.y || left.x - right.x)[0]; if (!scene || !canvas || !target) { return { ready: false, label: targetLabel }; } const bounds = target.getBounds(); const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, label: targetLabel, bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, label); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), 'Expected a visible interactive camp command for ' + label + ': ' + JSON.stringify(probe) ); return probe; } async function readCampTabControlPoint(page, tab) { const probe = await page.evaluate((targetTab) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const canvas = document.querySelector('canvas'); const button = scene?.tabButtons?.find((candidate) => candidate.tab === targetTab); const target = button?.bg; if (!scene || !canvas || !target?.active || !target.visible || !target.input?.enabled) { return { ready: false, tab: targetTab, availableTabs: scene?.tabButtons?.map((candidate) => candidate.tab) ?? [] }; } const bounds = target.getBounds(); const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, tab: targetTab, bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, tab); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), 'Expected a visible camp tab for ' + tab + ': ' + JSON.stringify(probe) ); return probe; } async function readSortieTextControlPoint(page, label) { const probe = await page.evaluate((targetLabel) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const canvas = document.querySelector('canvas'); const candidates = (scene?.sortieObjects ?? []) .filter( (object) => object?.type === 'Text' && object.text === targetLabel && object.active && object.visible && object.input?.enabled ) .sort((left, right) => left.y - right.y || left.x - right.x); const target = candidates[0]; if (!scene || !canvas || !target) { return { ready: false, label: targetLabel, candidateCount: candidates.length }; } const bounds = target.getBounds(); const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, label: targetLabel, candidateCount: candidates.length, bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, label); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), 'Expected a visible interactive sortie control for ' + label + ': ' + JSON.stringify(probe) ); return probe; } async function readCampContentTextControlPoint(page, label, occurrence = 0) { const probe = await page.evaluate(({ label, occurrence }) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const canvas = document.querySelector('canvas'); const candidates = (scene?.contentObjects ?? []) .filter((object) => object?.type === 'Text' && object?.text === label && object?.active && object?.visible) .sort((left, right) => left.y - right.y || left.x - right.x); const target = candidates[occurrence]; if (!scene || !canvas || !target) { return { ready: false, label, occurrence, candidateCount: candidates.length }; } const bounds = target.getBounds(); const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, label, occurrence, candidateCount: candidates.length, bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, { label, occurrence }); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), `Expected a visible Phaser camp text control "${label}" with a canvas-scaled click point: ${JSON.stringify(probe)}` ); return probe; } async function readCampSupplyUseControlPoint(page, supplyLabel) { const probe = await page.evaluate((supplyLabel) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const canvas = document.querySelector('canvas'); const texts = (scene?.contentObjects ?? []).filter((object) => object?.type === 'Text' && object?.active && object?.visible); const supplyTitle = texts.find((object) => object.text?.startsWith(`${supplyLabel} x`)); const useText = texts .filter((object) => object.text === '사용') .sort((left, right) => Math.abs(left.y - (supplyTitle?.y ?? 0)) - Math.abs(right.y - (supplyTitle?.y ?? 0)))[0]; if (!scene || !canvas || !supplyTitle || !useText) { return { ready: false, supplyLabel, supplyTitle: supplyTitle?.text ?? null, useCount: texts.filter((object) => object.text === '사용').length }; } const bounds = useText.getBounds(); const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, supplyLabel, supplyTitle: supplyTitle.text, bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, supplyLabel); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), `Expected a visible ${supplyLabel} supply use control: ${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; } function assertSortiePortraitRosterPage(view, expectedPage, expectedVisibleUnitIds) { const expectedStart = expectedVisibleUnitIds.length > 0 ? expectedPage * 6 + 1 : 0; const expectedEnd = expectedPage * 6 + expectedVisibleUnitIds.length; const hdBounds = { x: 0, y: 0, width: 1920, height: 1080 }; assert( view?.enabled === true && view.total === 23 && view.page === expectedPage && view.pageCount === 4 && view.pageSize === 6 && view.visibleCount === expectedVisibleUnitIds.length && view.start === expectedStart && view.end === expectedEnd && sameJsonValue(view.visibleUnitIds, expectedVisibleUnitIds) && view.previousEnabled === (expectedPage > 0) && view.nextEnabled === (expectedPage < 3) && isFiniteBounds(view.previousButtonBounds) && isFiniteBounds(view.nextButtonBounds) && boundsInside(view.previousButtonBounds, hdBounds) && boundsInside(view.nextButtonBounds, hdBounds) && view.cardBounds?.length === expectedVisibleUnitIds.length, `Expected exact six-officer portrait page ${expectedPage + 1}/4 metadata and navigation state: ${JSON.stringify({ expectedVisibleUnitIds, view })}` ); view.cardBounds.forEach((bounds, index) => { assert( bounds.unitId === expectedVisibleUnitIds[index] && isFiniteBounds(bounds) && boundsInside(bounds, hdBounds), `Expected portrait card ${index + 1} on page ${expectedPage + 1} to match its visible officer inside the FHD canvas: ${JSON.stringify({ bounds, expectedUnitId: expectedVisibleUnitIds[index] })}` ); }); } async function readSortiePortraitRosterNavigationPoint(page, direction) { const probe = await page.evaluate((navigationDirection) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const canvas = document.querySelector('canvas'); const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView; const bounds = navigationDirection === 'previous' ? view?.previousButtonBounds : view?.nextButtonBounds; const enabled = navigationDirection === 'previous' ? view?.previousEnabled : view?.nextEnabled; if (!scene || !canvas || !bounds || !enabled) { return { ready: false, direction: navigationDirection, enabled: enabled ?? false, bounds: bounds ?? null, view }; } const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, direction: navigationDirection, enabled, bounds, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, direction); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), `Expected an enabled portrait roster ${direction} control with a canvas-scaled click point: ${JSON.stringify(probe)}` ); return probe; } async function readSortiePortraitRosterCardPoint(page, unitId) { const target = await page.evaluate((targetUnitId) => { const state = window.__HEROS_DEBUG__?.camp(); const view = state?.sortiePortraitRosterView; const unitIndex = state?.sortiePortraitRoster?.findIndex((unit) => unit.id === targetUnitId) ?? -1; return { ready: Boolean(view?.enabled && unitIndex >= 0 && view.pageSize > 0), unitId: targetUnitId, unitIndex, currentPage: view?.page ?? null, targetPage: unitIndex >= 0 && view?.pageSize > 0 ? Math.floor(unitIndex / view.pageSize) : null, pageCount: view?.pageCount ?? null }; }, unitId); assert( target.ready === true && Number.isInteger(target.currentPage) && Number.isInteger(target.targetPage) && target.targetPage >= 0 && target.targetPage < target.pageCount, `Expected ${unitId} to belong to a valid portrait roster page: ${JSON.stringify(target)}` ); let currentPage = target.currentPage; while (currentPage !== target.targetPage) { const direction = currentPage < target.targetPage ? 'next' : 'previous'; const navigationPoint = await readSortiePortraitRosterNavigationPoint(page, direction); await page.mouse.click(navigationPoint.x, navigationPoint.y); const expectedPage = currentPage + (direction === 'next' ? 1 : -1); await page.waitForFunction( (pageIndex) => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView?.page === pageIndex, expectedPage, { timeout: 30000 } ); currentPage = expectedPage; } await page.waitForFunction( ({ pageIndex, targetUnitId }) => { const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView; return view?.page === pageIndex && view.visibleUnitIds?.includes(targetUnitId) && view.cardBounds?.some((card) => card.unitId === targetUnitId); }, { pageIndex: target.targetPage, targetUnitId: unitId }, { timeout: 30000 } ); const probe = await page.evaluate((targetUnitId) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const canvas = document.querySelector('canvas'); const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView; const bounds = view?.cardBounds?.find((card) => card.unitId === targetUnitId); if (!scene || !canvas || !bounds) { return { ready: false, unitId: targetUnitId, bounds: bounds ?? null, view }; } const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, unitId: targetUnitId, bounds, x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height }; }, unitId); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), `Expected a visible portrait roster card for ${unitId} with a canvas-scaled pointer 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; delete report.sortieReview; 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 isFiniteBounds(bounds) { return Boolean( bounds && Number.isFinite(bounds.x) && Number.isFinite(bounds.y) && Number.isFinite(bounds.width) && Number.isFinite(bounds.height) && bounds.width > 0 && bounds.height > 0 ); } function boundsInside(inner, outer, tolerance = 1) { return isFiniteBounds(inner) && isFiniteBounds(outer) && inner.x >= outer.x - tolerance && inner.y >= outer.y - tolerance && inner.x + inner.width <= outer.x + outer.width + tolerance && inner.y + inner.height <= outer.y + outer.height + tolerance; } 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 sameSortieOrderCommand(value, expected) { const commandFields = [ 'source', 'role', 'actorId', 'usedTurn', 'effectPending', 'effectValue', 'statusesCleared', 'effectLost' ]; const snapshot = (command) => command ? Object.fromEntries(commandFields.map((field) => [field, command[field]])) : null; return sameJsonValue(snapshot(value), snapshot(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(' ')}`); } }