diff --git a/package.json b/package.json index 75a787b..a7e4d44 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,10 @@ "verify:audio-assets": "node scripts/verify-audio-asset-data.mjs", "verify:desktop-viewport": "node scripts/verify-desktop-browser-viewport.mjs", "verify:static-data": "node scripts/verify-static-data.mjs", - "verify:flow": "node scripts/verify-flow.mjs", + "verify:flow": "node scripts/verify-flow-segments.mjs", + "verify:flow:manifest": "node scripts/verify-flow-segments.mjs --list-segments", + "verify:flow:monolith": "node scripts/verify-flow.mjs", + "verify:flow:segment": "node scripts/verify-flow.mjs", "verify:interaction-ux": "node scripts/verify-interaction-ux.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", diff --git a/scripts/campaign-flow-segments.mjs b/scripts/campaign-flow-segments.mjs new file mode 100644 index 0000000..ced070e --- /dev/null +++ b/scripts/campaign-flow-segments.mjs @@ -0,0 +1,136 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; + +const campSkinsUrl = new URL('../src/game/data/campSkins.ts', import.meta.url); +const campaignRoutingUrl = new URL('../src/game/state/campaignRouting.ts', import.meta.url); + +const specialCheckpointSteps = { + 'hanzhong-shuhan': 'shu-han-foundation-camp', + 'yiling-baidi': 'baidi-entrustment-camp', + nanzhong: 'northern-campaign-prep-camp', + northern: 'ending-complete' +}; + +export async function readCampaignFlowSegments() { + const [campSkinsSource, campaignRoutingSource] = await Promise.all([ + readFile(campSkinsUrl, 'utf8'), + readFile(campaignRoutingUrl, 'utf8') + ]); + const routes = parseCampaignRoutes(campaignRoutingSource); + const arcs = parseCampSkinBattleArcs(campSkinsSource); + + validateCampaignRoutes(routes); + validateCampSkinArcs(arcs, routes.length); + + const segments = arcs.map((arc, index) => { + const segmentRoutes = routes.slice(arc.firstBattleOrdinal - 1, arc.lastBattleOrdinal); + const lastRoute = segmentRoutes.at(-1); + const chapterLabel = readCampSkinChapterLabel(campSkinsSource, arc.skinId); + return { + index, + id: arc.skinId, + label: chapterLabel, + firstBattleOrdinal: arc.firstBattleOrdinal, + lastBattleOrdinal: arc.lastBattleOrdinal, + firstBattleId: segmentRoutes[0]?.battleId, + lastBattleId: lastRoute?.battleId, + checkpointStep: specialCheckpointSteps[arc.skinId] ?? lastRoute?.campStep, + battleIds: segmentRoutes.map((route) => route.battleId) + }; + }); + + const manifestHash = createHash('sha256') + .update(JSON.stringify(segments.map(({ id, firstBattleOrdinal, lastBattleOrdinal, checkpointStep, battleIds }) => ({ + id, + firstBattleOrdinal, + lastBattleOrdinal, + checkpointStep, + battleIds + })))) + .digest('hex'); + + return { routes, segments, manifestHash }; +} + +export function campaignFlowSegmentSummary(segment) { + return `${segment.id} (${segment.label}, ${segment.firstBattleOrdinal}-${segment.lastBattleOrdinal}전)`; +} + +function parseCampaignRoutes(source) { + const mapBody = source.match(/const battleIdByCampaignStep[\s\S]*?=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ''; + return [...mapBody.matchAll(/^\s*'([^']+-battle)':\s*'([^']+)',?\s*$/gm)].map((match) => ({ + battleStep: match[1], + battleId: match[2], + campStep: match[1].replace(/-battle$/, '-camp') + })); +} + +function parseCampSkinBattleArcs(source) { + const arcBody = source.match(/export const campSkinBattleArcs:[\s\S]*?=\s*\[([\s\S]*?)\n\];/)?.[1] ?? ''; + return [...arcBody.matchAll( + /\{\s*skinId:\s*'([^']+)',\s*firstBattleOrdinal:\s*(\d+),\s*lastBattleOrdinal:\s*(\d+)\s*\}/g + )].map((match) => ({ + skinId: match[1], + firstBattleOrdinal: Number(match[2]), + lastBattleOrdinal: Number(match[3]) + })); +} + +function readCampSkinChapterLabel(source, skinId) { + const marker = `id: '${skinId}'`; + const markerIndex = source.indexOf(marker); + const nearbySource = markerIndex >= 0 ? source.slice(markerIndex, markerIndex + 900) : ''; + const label = nearbySource.match(/chapterLabel:\s*'([^']+)'/)?.[1]; + if (!label) { + throw new Error(`Could not find the camp chapter label for ${skinId}.`); + } + return label; +} + +function validateCampaignRoutes(routes) { + const battleIds = routes.map((route) => route.battleId); + const battleSteps = routes.map((route) => route.battleStep); + if ( + routes.length !== 66 || + new Set(battleIds).size !== routes.length || + new Set(battleSteps).size !== routes.length + ) { + throw new Error(`Campaign routing must contain 66 unique ordered battles: ${JSON.stringify({ + routeCount: routes.length, + battleIdCount: new Set(battleIds).size, + battleStepCount: new Set(battleSteps).size + })}`); + } +} + +function validateCampSkinArcs(arcs, routeCount) { + if (arcs.length === 0) { + throw new Error('No camp-skin battle arcs were found.'); + } + const segmentIds = arcs.map(({ skinId }) => skinId); + if (new Set(segmentIds).size !== segmentIds.length) { + throw new Error(`Camp-skin battle arc IDs must be unique: ${JSON.stringify(segmentIds)}`); + } + const invalidSegmentIds = segmentIds.filter((segmentId) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(segmentId)); + if (invalidSegmentIds.length > 0) { + throw new Error(`Camp-skin battle arc IDs must be safe kebab-case paths: ${JSON.stringify(invalidSegmentIds)}`); + } + + let expectedFirstOrdinal = 1; + for (const arc of arcs) { + if ( + arc.firstBattleOrdinal !== expectedFirstOrdinal || + arc.lastBattleOrdinal < arc.firstBattleOrdinal + ) { + throw new Error(`Camp-skin battle arcs must be contiguous and ordered: ${JSON.stringify({ + arc, + expectedFirstOrdinal + })}`); + } + expectedFirstOrdinal = arc.lastBattleOrdinal + 1; + } + + if (expectedFirstOrdinal !== routeCount + 1) { + throw new Error(`Camp-skin battle arcs must cover all ${routeCount} battles; next ordinal was ${expectedFirstOrdinal}.`); + } +} diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index c1b8a00..8e4e6c1 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -29,9 +29,11 @@ try { acknowledgeCampaignVictoryReward, campaignVictoryRewardPresentation, campaignStorageKey, + dismissCampaignVictoryRewardNotice, applyCampBondExp, applyCampVisitReward, getCampaignState, + getPendingCampaignVictoryRewardNoticeReport, getPendingCampaignVictoryRewardReport, hasCampaignSave, loadCampaignState, @@ -1087,9 +1089,23 @@ try { assert( pendingVictoryRewardReport?.battleId === firstScenario.id && pendingVictoryRewardReport.rewardGold === firstBattleReport.rewardGold && - pendingVictoryRewardReport.campaignRewards?.equipment[0] === firstBattleReport.campaignRewards.equipment[0], + pendingVictoryRewardReport.campaignRewards?.equipment[0] === firstBattleReport.campaignRewards.equipment[0] && + getPendingCampaignVictoryRewardNoticeReport()?.battleId === firstScenario.id, `Expected an unacknowledged victory to expose its persisted reward report: ${JSON.stringify(pendingVictoryRewardReport)}` ); + const dismissedVictoryRewardNotice = dismissCampaignVictoryRewardNotice(firstScenario.id); + const repeatedVictoryRewardNoticeDismissal = dismissCampaignVictoryRewardNotice(firstScenario.id); + const dismissedVictoryRewardNoticeSave = JSON.parse(storage.get(campaignStorageKey)); + const dismissedVictoryRewardNoticeSlotSave = JSON.parse(storage.get(`${campaignStorageKey}:slot-1`)); + assert( + JSON.stringify(dismissedVictoryRewardNotice.dismissedVictoryRewardNoticeBattleIds) === JSON.stringify([firstScenario.id]) && + JSON.stringify(repeatedVictoryRewardNoticeDismissal.dismissedVictoryRewardNoticeBattleIds) === JSON.stringify([firstScenario.id]) && + dismissedVictoryRewardNoticeSave.dismissedVictoryRewardNoticeBattleIds.includes(firstScenario.id) && + dismissedVictoryRewardNoticeSlotSave.dismissedVictoryRewardNoticeBattleIds.includes(firstScenario.id) && + getPendingCampaignVictoryRewardReport()?.battleId === firstScenario.id && + getPendingCampaignVictoryRewardNoticeReport() === undefined, + `Expected notice dismissal to persist idempotently without consuming the underlying reward categories: ${JSON.stringify({ dismissedVictoryRewardNotice, dismissedVictoryRewardNoticeSave })}` + ); const acknowledgedVictoryReward = acknowledgeCampaignVictoryReward(firstScenario.id); const repeatedVictoryRewardAcknowledgement = acknowledgeCampaignVictoryReward(firstScenario.id); const acknowledgedVictoryRewardSave = JSON.parse(storage.get(campaignStorageKey)); @@ -1102,12 +1118,18 @@ try { ); const corruptedFutureAcknowledgementSave = JSON.parse(storage.get(campaignStorageKey)); corruptedFutureAcknowledgementSave.acknowledgedVictoryRewardBattleIds.push('second-battle-yellow-turban-pursuit'); + corruptedFutureAcknowledgementSave.dismissedVictoryRewardNoticeBattleIds.push( + firstScenario.id, + 'second-battle-yellow-turban-pursuit', + 'unknown-battle' + ); storage.set(campaignStorageKey, JSON.stringify(corruptedFutureAcknowledgementSave)); const normalizedFutureAcknowledgementSave = loadCampaignState(); assert( normalizedFutureAcknowledgementSave.acknowledgedVictoryRewardBattleIds.includes(firstScenario.id) && - !normalizedFutureAcknowledgementSave.acknowledgedVictoryRewardBattleIds.includes('second-battle-yellow-turban-pursuit'), - `Expected acknowledgement normalization to retain recorded victories while rejecting future known battle ids without a victory: ${JSON.stringify(normalizedFutureAcknowledgementSave.acknowledgedVictoryRewardBattleIds)}` + !normalizedFutureAcknowledgementSave.acknowledgedVictoryRewardBattleIds.includes('second-battle-yellow-turban-pursuit') && + JSON.stringify(normalizedFutureAcknowledgementSave.dismissedVictoryRewardNoticeBattleIds) === JSON.stringify([firstScenario.id]), + `Expected acknowledgement normalization to retain recorded victories while rejecting duplicate, unknown, and future notice dismissals: ${JSON.stringify(normalizedFutureAcknowledgementSave)}` ); setFirstBattleReport({ ...firstBattleReport, diff --git a/scripts/verify-flow-segment-data.mjs b/scripts/verify-flow-segment-data.mjs new file mode 100644 index 0000000..7811a3b --- /dev/null +++ b/scripts/verify-flow-segment-data.mjs @@ -0,0 +1,63 @@ +import { readFile } from 'node:fs/promises'; +import { readCampaignFlowSegments } from './campaign-flow-segments.mjs'; + +const { routes, segments, manifestHash } = await readCampaignFlowSegments(); +const flowSource = await readFile(new URL('./verify-flow.mjs', import.meta.url), 'utf8'); +const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); + +assert(segments.length === 12, `Expected 12 campaign flow segments, received ${segments.length}.`); +assert(routes.length === 66, `Expected 66 campaign battle routes, received ${routes.length}.`); +assert(/^[a-f0-9]{64}$/.test(manifestHash), `Expected a SHA-256 flow manifest hash, received ${manifestHash}.`); + +const flattenedBattleIds = segments.flatMap(({ battleIds }) => battleIds); +assert( + JSON.stringify(flattenedBattleIds) === JSON.stringify(routes.map(({ battleId }) => battleId)), + 'Campaign flow segments must preserve the canonical 66-battle route order.' +); + +const specialCheckpointSteps = { + 'hanzhong-shuhan': 'shu-han-foundation-camp', + 'yiling-baidi': 'baidi-entrustment-camp', + nanzhong: 'northern-campaign-prep-camp', + northern: 'ending-complete' +}; + +for (const segment of segments) { + assert(segment.index === segments.indexOf(segment), `Flow segment ${segment.id} has an invalid index.`); + assert( + countOccurrences(flowSource, `shouldRunFlowSegment('${segment.id}')`) === 1, + `Flow segment ${segment.id} must have exactly one executable body boundary.` + ); + assert( + countOccurrences(flowSource, `completeFlowSegment(page, '${segment.id}')`) === 1, + `Flow segment ${segment.id} must have exactly one completion checkpoint.` + ); + const specialStep = specialCheckpointSteps[segment.id]; + if (specialStep) { + assert( + segment.checkpointStep === specialStep, + `Flow segment ${segment.id} must end at ${specialStep}, received ${segment.checkpointStep}.` + ); + } +} + +assert( + packageJson.scripts?.['verify:flow'] === 'node scripts/verify-flow-segments.mjs', + 'The default verify:flow command must use the segmented orchestrator.' +); +assert( + packageJson.scripts?.['verify:flow:monolith'] === 'node scripts/verify-flow.mjs', + 'The legacy monolith flow command must remain available for comparison.' +); + +console.log(`Verified ${segments.length} campaign flow segments across ${routes.length} ordered battles (${manifestHash.slice(0, 12)}).`); + +function countOccurrences(source, needle) { + return source.split(needle).length - 1; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/scripts/verify-flow-segments.mjs b/scripts/verify-flow-segments.mjs new file mode 100644 index 0000000..e8957f4 --- /dev/null +++ b/scripts/verify-flow-segments.mjs @@ -0,0 +1,585 @@ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, relative, resolve } from 'node:path'; +import { + campaignFlowSegmentSummary, + readCampaignFlowSegments +} from './campaign-flow-segments.mjs'; + +const allowedCliOptions = new Set([ + 'segment', + 'from', + 'to', + 'resume', + 'output-dir', + 'checkpoint-dir', + 'screenshots', + 'heartbeat-ms', + 'segment-timeout-ms', + 'list-segments' +]); +const booleanCliOptions = new Set(['resume', 'list-segments']); +const cliOptions = parseCliOptions(process.argv.slice(2)); +if (cliOptions.segment && (cliOptions.from || cliOptions.to)) { + throw new Error('--segment cannot be combined with --from or --to.'); +} +const { segments, manifestHash } = await readCampaignFlowSegments(); +const selectedSegments = selectSegments(segments, cliOptions); +const targetUrl = process.env.VERIFY_URL ?? 'http://127.0.0.1:41743/'; +const outputDirectory = resolve(cliOptions['output-dir'] ?? process.env.VERIFY_FLOW_OUTPUT_DIR ?? 'dist/verify-flow'); +const checkpointDirectory = resolve( + cliOptions['checkpoint-dir'] ?? process.env.VERIFY_FLOW_CHECKPOINT_DIR ?? `${outputDirectory}/checkpoints` +); +const resume = booleanOption(cliOptions.resume ?? process.env.VERIFY_FLOW_RESUME, 'resume'); +const screenshots = cliOptions.screenshots ?? process.env.VERIFY_FLOW_SCREENSHOTS ?? 'failure'; +const heartbeatMs = positiveNumber( + cliOptions['heartbeat-ms'] ?? process.env.VERIFY_FLOW_HEARTBEAT_MS ?? '15000', + 'heartbeat-ms' +); +const segmentTimeoutMs = positiveNumber( + cliOptions['segment-timeout-ms'] ?? process.env.VERIFY_FLOW_SEGMENT_TIMEOUT_MS ?? '720000', + 'segment-timeout-ms' +); + +if (booleanOption(cliOptions['list-segments'], 'list-segments')) { + for (const segment of segments) { + console.log(campaignFlowSegmentSummary(segment)); + } + process.exit(0); +} + +if (!['all', 'failure', 'none'].includes(screenshots)) { + throw new Error(`Unknown screenshot mode "${screenshots}". Use all, failure, or none.`); +} + +assertSafeOutputDirectory(outputDirectory); +assertNestedDirectory(outputDirectory, checkpointDirectory, 'Flow checkpoint directory'); + +if (!resume) { + if (selectedSegments[0]?.index !== 0) { + throw new Error('A run starting after the first segment requires --resume=1 and an existing previous checkpoint.'); + } + await rm(outputDirectory, { recursive: true, force: true }); +} +await mkdir(checkpointDirectory, { recursive: true }); + +const runStartedAt = Date.now(); +const summaryPath = resolve(outputDirectory, 'summary.json'); +const results = []; +let serverProcess; + +await writeSummary(summaryPath, manifestHash, results, runStartedAt, selectedSegments, segments); +console.log(`[verify-flow] manifest ${manifestHash.slice(0, 12)} · ${selectedSegments.length}/${segments.length} segments`); + +try { + serverProcess = await ensureLocalServer(targetUrl); + for (const segment of selectedSegments) { + const previousSegment = segments[segment.index - 1]; + if (previousSegment) { + const previousCheckpoint = resolve(checkpointDirectory, `${previousSegment.id}.json`); + if (!existsSync(previousCheckpoint)) { + throw new Error(`Missing prerequisite checkpoint for ${segment.id}: ${previousCheckpoint}`); + } + await validateSegmentCheckpoint(previousSegment, checkpointDirectory, manifestHash, segments); + } + + const startedAt = Date.now(); + await rm(resolve(outputDirectory, segment.id), { recursive: true, force: true }); + console.log(`[verify-flow] segment start · ${campaignFlowSegmentSummary(segment)}`); + const exitCode = await runSegmentProcess(segment, { + checkpointDirectory, + heartbeatMs, + manifestHash, + outputDirectory, + screenshots, + segmentTimeoutMs, + targetUrl + }); + const result = { + id: segment.id, + label: segment.label, + firstBattleOrdinal: segment.firstBattleOrdinal, + lastBattleOrdinal: segment.lastBattleOrdinal, + durationMs: Date.now() - startedAt, + exitCode, + status: exitCode === 0 ? 'passed' : 'failed' + }; + results.push(result); + if (exitCode !== 0) { + throw new Error(`Flow segment ${segment.id} failed with exit code ${exitCode}.`); + } + try { + await validateSegmentArtifacts(segment, { + checkpointDirectory, + manifestHash, + outputDirectory, + segments + }); + result.artifactValidation = 'passed'; + } catch (error) { + result.status = 'failed'; + result.artifactValidation = 'failed'; + throw error; + } + await writeSummary(summaryPath, manifestHash, results, runStartedAt, selectedSegments, segments); + console.log(`[verify-flow] segment pass · ${segment.id} · ${formatDuration(result.durationMs)}`); + } + + console.log(`[verify-flow] all selected segments passed · ${formatDuration(Date.now() - runStartedAt)}`); +} catch (error) { + await writeSummary(summaryPath, manifestHash, results, runStartedAt, selectedSegments, segments, error); + throw error; +} finally { + await terminateProcessTree(serverProcess); +} + +async function runSegmentProcess(segment, options) { + const progressPath = resolve(options.outputDirectory, segment.id, 'progress.jsonl'); + await mkdir(resolve(options.outputDirectory, segment.id), { recursive: true }); + const args = [ + 'scripts/verify-flow.mjs', + `--segment=${segment.id}`, + `--checkpoint-dir=${options.checkpointDirectory}`, + `--progress=${progressPath}`, + `--screenshots=${options.screenshots}`, + `--heartbeat-ms=${options.heartbeatMs}`, + `--manifest-hash=${options.manifestHash}` + ]; + + return new Promise((resolveExit, reject) => { + const child = spawn(process.execPath, args, { + cwd: process.cwd(), + env: { ...process.env, VERIFY_URL: options.targetUrl }, + stdio: 'inherit', + windowsHide: true + }); + let timedOut = false; + let settled = false; + const settleExit = (code) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolveExit(code); + }; + const settleError = (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + reject(error); + }; + const timeout = setTimeout(() => { + timedOut = true; + console.error(`[verify-flow] segment timeout · ${segment.id} · ${formatDuration(options.segmentTimeoutMs)}`); + void (async () => { + await terminateProcessTree(child); + await writeTimeoutFailure(segment, options); + settleExit(124); + })().catch(settleError); + }, options.segmentTimeoutMs); + + child.once('error', (error) => { + if (!timedOut) { + settleError(error); + } + }); + child.once('exit', (code, signal) => { + if (timedOut) { + return; + } + if (signal) { + console.error(`[verify-flow] segment ${segment.id} ended by signal ${signal}.`); + } + settleExit(code ?? 1); + }); + }); +} + +async function writeSummary(path, hash, segmentResults, startedAt, selectedSegments, manifestSegments, error) { + const selectedSegmentIds = selectedSegments.map(({ id }) => id); + const fullManifestSelected = selectedSegmentIds.length === manifestSegments.length && + selectedSegmentIds.every((id, index) => id === manifestSegments[index]?.id); + const allSelectedFinished = segmentResults.length === selectedSegments.length; + const allSelectedPassed = allSelectedFinished && segmentResults.every(({ status }) => status === 'passed'); + const status = error + ? 'failed' + : !allSelectedFinished + ? 'running' + : allSelectedPassed && fullManifestSelected + ? 'full-manifest-passed' + : allSelectedPassed + ? 'partial-passed' + : 'failed'; + await writeTextAtomically(path, `${JSON.stringify({ + version: 1, + manifestHash: hash, + status, + startedAt: new Date(startedAt).toISOString(), + updatedAt: new Date().toISOString(), + durationMs: Date.now() - startedAt, + manifestSegmentCount: manifestSegments.length, + selectedSegmentCount: selectedSegments.length, + selectedSegmentIds, + fullManifestSelected, + finishedSegments: segmentResults.length, + passedSegments: segmentResults.filter(({ status: resultStatus }) => resultStatus === 'passed').length, + results: segmentResults, + error: error instanceof Error ? { name: error.name, message: error.message } : error ? { message: String(error) } : null + }, null, 2)}\n`); +} + +async function validateSegmentArtifacts(segment, options) { + await validateSegmentCheckpoint(segment, options.checkpointDirectory, options.manifestHash, options.segments); + const progressPath = resolve(options.outputDirectory, segment.id, 'progress.jsonl'); + if (!existsSync(progressPath)) { + throw new Error(`Flow segment ${segment.id} exited successfully without a progress log: ${progressPath}`); + } + const progressSource = await readFile(progressPath, 'utf8'); + const events = progressSource + .split(/\r?\n/) + .filter(Boolean) + .map((line, index) => { + try { + return JSON.parse(line); + } catch (error) { + throw new Error( + `Flow segment ${segment.id} has invalid progress JSON on line ${index + 1}: ${error instanceof Error ? error.message : String(error)}` + ); + } + }); + const startEvent = events.find(({ type }) => type === 'start'); + const checkpointEvent = events.findLast(({ type }) => type === 'checkpoint'); + const passEvent = events.findLast(({ type }) => type === 'segment-pass'); + if ( + startEvent?.segment !== segment.id || + startEvent.manifestHash !== options.manifestHash || + checkpointEvent?.segment !== segment.id || + checkpointEvent.campaignStep !== segment.checkpointStep || + passEvent?.segment !== segment.id + ) { + throw new Error(`Flow segment ${segment.id} did not emit the required start/checkpoint/pass progress contract.`); + } +} + +async function validateSegmentCheckpoint(segment, checkpointDirectory, hash, allSegments) { + const checkpointPath = resolve(checkpointDirectory, `${segment.id}.json`); + if (!existsSync(checkpointPath)) { + throw new Error(`Flow segment ${segment.id} exited successfully without a checkpoint: ${checkpointPath}`); + } + let checkpoint; + try { + checkpoint = JSON.parse(await readFile(checkpointPath, 'utf8')); + } catch (error) { + throw new Error( + `Flow checkpoint ${segment.id} is not valid JSON: ${error instanceof Error ? error.message : String(error)}` + ); + } + if ( + checkpoint?.version !== 1 || + checkpoint.manifestHash !== hash || + checkpoint.segment?.id !== segment.id || + checkpoint.segment?.lastBattleOrdinal !== segment.lastBattleOrdinal || + checkpoint.segment?.lastBattleId !== segment.lastBattleId || + checkpoint.segment?.checkpointStep !== segment.checkpointStep || + !checkpoint.localStorage || + typeof checkpoint.localStorage !== 'object' + ) { + throw new Error(`Flow checkpoint ${segment.id} does not match the active manifest.`); + } + const globalRaw = checkpoint.localStorage['heros-web:campaign-state']; + const slotRaw = checkpoint.localStorage['heros-web:campaign-state:slot-1']; + if (!globalRaw || !slotRaw || globalRaw !== slotRaw) { + throw new Error(`Flow checkpoint ${segment.id} must contain identical global and slot-1 campaign saves.`); + } + let campaign; + try { + campaign = JSON.parse(slotRaw); + } catch (error) { + throw new Error( + `Flow checkpoint ${segment.id} campaign save is not valid JSON: ${error instanceof Error ? error.message : String(error)}` + ); + } + const expectedBattleIds = allSegments + .slice(0, segment.index + 1) + .flatMap((candidate) => candidate.battleIds); + const completedBattleIds = Object.keys(campaign?.battleHistory ?? {}); + const exactBattlePrefix = expectedBattleIds.length === completedBattleIds.length && + expectedBattleIds.every((battleId, index) => completedBattleIds[index] === battleId); + const allBattlesWon = expectedBattleIds.every( + (battleId) => campaign?.battleHistory?.[battleId]?.outcome === 'victory' + ); + if ( + campaign?.version !== 1 || + campaign?.activeSaveSlot !== 1 || + campaign?.step !== segment.checkpointStep || + campaign?.latestBattleId !== segment.lastBattleId || + !exactBattlePrefix || + !allBattlesWon + ) { + throw new Error(`Flow checkpoint ${segment.id} campaign position or battle history is invalid.`); + } +} + +async function writeTimeoutFailure(segment, options) { + const segmentDirectory = resolve(options.outputDirectory, segment.id); + const progressPath = resolve(segmentDirectory, 'progress.jsonl'); + let lastProgressEvent = null; + try { + const lines = (await readFile(progressPath, 'utf8')).split(/\r?\n/).filter(Boolean); + if (lines.length > 0) { + lastProgressEvent = JSON.parse(lines.at(-1)); + } + } catch { + lastProgressEvent = null; + } + await writeTextAtomically(resolve(segmentDirectory, 'timeout-state.json'), `${JSON.stringify({ + version: 1, + manifestHash: options.manifestHash, + timestamp: new Date().toISOString(), + segment: segment.id, + timeoutMs: options.segmentTimeoutMs, + lastProgressEvent + }, null, 2)}\n`); +} + +async function writeTextAtomically(path, contents) { + await mkdir(dirname(path), { recursive: true }); + const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`; + try { + await writeFile(temporaryPath, contents, 'utf8'); + await rename(temporaryPath, path); + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => {}); + throw error; + } +} + +function selectSegments(allSegments, options) { + const onlyId = options.segment; + const fromId = options.from; + const toId = options.to; + if (onlyId) { + const segment = allSegments.find((candidate) => candidate.id === onlyId); + if (!segment) { + throw new Error(`Unknown flow segment "${onlyId}". Use one of: ${allSegments.map(({ id }) => id).join(', ')}`); + } + return [segment]; + } + + const fromIndex = fromId ? allSegments.findIndex(({ id }) => id === fromId) : 0; + const toIndex = toId ? allSegments.findIndex(({ id }) => id === toId) : allSegments.length - 1; + if (fromIndex < 0 || toIndex < 0 || fromIndex > toIndex) { + throw new Error(`Invalid segment range: ${fromId ?? allSegments[0].id}..${toId ?? allSegments.at(-1).id}`); + } + return allSegments.slice(fromIndex, toIndex + 1); +} + +async function ensureLocalServer(url) { + if (await canReach(url)) { + return undefined; + } + const parsed = new URL(url); + if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) { + throw new Error(`No server responded at ${url}`); + } + const child = spawn( + process.execPath, + ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', parsed.port || '41743', '--strictPort'], + { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true } + ); + let startupError; + let stderr = ''; + child.once('error', (error) => { + startupError = error; + }); + child.stderr?.on('data', (chunk) => { + stderr = `${stderr}${chunk}`.slice(-6000); + }); + for (let attempt = 0; attempt < 120; attempt += 1) { + if (startupError) { + throw new Error(`Could not start the segmented flow server: ${startupError.message}`); + } + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error( + `Segmented flow server exited before becoming ready (${child.exitCode ?? child.signalCode}).${stderr ? `\n${stderr.trim()}` : ''}` + ); + } + if (await canReach(url)) { + return child; + } + await delay(500); + } + await terminateProcessTree(child); + throw new Error(`Could not start the segmented flow server at ${url}`); +} + +async function terminateProcessTree(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) { + return; + } + if (process.platform === 'win32' && child.pid) { + await runProcessTreeTerminator(child.pid); + } else { + try { + child.kill('SIGTERM'); + } catch { + // The process may have exited between the state check and the signal. + } + } + await waitForProcessExit(child, 3000); + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill('SIGKILL'); + } catch { + // The process may have exited between the state check and the signal. + } + await waitForProcessExit(child, 2000); + } + if (child.exitCode === null && child.signalCode === null) { + throw new Error(`Could not terminate process tree for PID ${child.pid ?? 'unknown'}.`); + } +} + +async function runProcessTreeTerminator(pid) { + await new Promise((resolveTermination) => { + const terminator = spawn('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true + }); + let settled = false; + const finish = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolveTermination(); + }; + const timeout = setTimeout(() => { + terminator.kill(); + finish(); + }, 5000); + terminator.once('error', finish); + terminator.once('exit', finish); + }); +} + +async function waitForProcessExit(child, timeoutMs) { + if (!child || child.exitCode !== null || child.signalCode !== null) { + return; + } + await new Promise((resolveExit) => { + let settled = false; + const finish = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + child.off('exit', finish); + child.off('error', finish); + resolveExit(); + }; + const timeout = setTimeout(finish, timeoutMs); + child.once('exit', finish); + child.once('error', finish); + }); +} + +async function canReach(url) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); + return response.ok; + } catch { + return false; + } +} + +function parseCliOptions(args) { + const options = {}; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg.startsWith('--')) { + throw new Error(`Unexpected flow runner argument: ${arg}`); + } + const option = arg.slice(2); + const equalsIndex = option.indexOf('='); + const key = equalsIndex >= 0 ? option.slice(0, equalsIndex) : option; + const inlineValue = equalsIndex >= 0 ? option.slice(equalsIndex + 1) : undefined; + if (!allowedCliOptions.has(key)) { + throw new Error(`Unknown flow runner option: --${key}`); + } + if (Object.hasOwn(options, key)) { + throw new Error(`Flow runner option was provided more than once: --${key}`); + } + if (inlineValue !== undefined) { + if (!inlineValue && !booleanCliOptions.has(key)) { + throw new Error(`Flow runner option --${key} requires a value.`); + } + options[key] = inlineValue; + continue; + } + const next = args[index + 1]; + if (next && !next.startsWith('--')) { + options[key] = next; + index += 1; + } else if (booleanCliOptions.has(key)) { + options[key] = '1'; + } else { + throw new Error(`Flow runner option --${key} requires a value.`); + } + } + return options; +} + +function positiveNumber(value, label) { + const number = Math.round(Number(value)); + if (!Number.isFinite(number) || number <= 0) { + throw new Error(`${label} must be a positive number; received ${value}.`); + } + return number; +} + +function booleanOption(value, label) { + if (value === undefined) { + return false; + } + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + throw new Error(`${label} must be a boolean value; received ${value}.`); +} + +function assertSafeOutputDirectory(path) { + const distRoot = resolve('dist'); + const relativePath = relative(distRoot, path); + if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { + throw new Error(`Flow output directory must be a dedicated subdirectory of dist: ${path}`); + } +} + +function assertNestedDirectory(parent, path, label) { + const relativePath = relative(parent, path); + if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { + throw new Error(`${label} must be a dedicated subdirectory of ${parent}: ${path}`); + } +} + +function formatDuration(ms) { + const totalSeconds = Math.round(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; +} + +function delay(ms) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); +} diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index eaa166d..3d8d504 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -1,8 +1,77 @@ import { spawn } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { basename, dirname, resolve } from 'node:path'; import { chromium } from 'playwright'; +import { readCampaignFlowSegments } from './campaign-flow-segments.mjs'; import { desktopBrowserContextOptions } from './desktop-browser-viewport.mjs'; +const allowedFlowCliOptions = new Set([ + 'segment', + 'checkpoint-dir', + 'progress', + 'screenshots', + 'heartbeat-ms', + 'manifest-hash', + 'list-segments' +]); +const booleanFlowCliOptions = new Set(['list-segments']); +const flowCliOptions = parseFlowCliOptions(process.argv.slice(2)); +const { + segments: campaignFlowSegments, + manifestHash: campaignFlowManifestHash +} = await readCampaignFlowSegments(); +const requestedFlowSegmentId = flowCliOptions.segment; +const requestedFlowSegment = requestedFlowSegmentId + ? campaignFlowSegments.find((segment) => segment.id === requestedFlowSegmentId) + : undefined; +if (requestedFlowSegmentId && !requestedFlowSegment) { + throw new Error( + `Unknown flow segment "${requestedFlowSegmentId}". Use one of: ${campaignFlowSegments.map(({ id }) => id).join(', ')}` + ); +} +const requestedManifestHash = flowCliOptions['manifest-hash']; +if ( + requestedManifestHash && + requestedManifestHash !== campaignFlowManifestHash +) { + throw new Error( + `Flow manifest hash mismatch: expected ${campaignFlowManifestHash}, received ${requestedManifestHash}.` + ); +} +if (flowBooleanOption(flowCliOptions['list-segments'], 'list-segments')) { + for (const segment of campaignFlowSegments) { + console.log( + `${segment.id}\t${segment.firstBattleOrdinal}-${segment.lastBattleOrdinal}\t${segment.label}\t${segment.lastBattleId}` + ); + } + console.log(`manifest\t${campaignFlowManifestHash}`); + process.exit(0); +} + +const segmentedFlowRun = Boolean(requestedFlowSegment); +const flowCheckpointDirectory = resolve( + flowCliOptions['checkpoint-dir'] ?? 'dist/verify-flow/checkpoints' +); +const flowProgressPath = flowCliOptions.progress + ? resolve(flowCliOptions.progress) + : segmentedFlowRun + ? resolve(`dist/verify-flow/${requestedFlowSegment.id}/progress.jsonl`) + : undefined; +const flowScreenshotMode = flowCliOptions.screenshots ?? (segmentedFlowRun ? 'failure' : 'all'); +if (!['all', 'failure', 'none'].includes(flowScreenshotMode)) { + throw new Error(`Unknown screenshot mode "${flowScreenshotMode}". Use all, failure, or none.`); +} +const flowHeartbeatMs = segmentedFlowRun || flowCliOptions['heartbeat-ms'] + ? positiveFlowNumber(flowCliOptions['heartbeat-ms'] ?? '15000', 'heartbeat-ms') + : 0; +const flowStartedAt = Date.now(); +let activeFlowSegmentId = requestedFlowSegment?.id ?? 'monolith'; +let lastFlowProgress = 'initializing'; +let flowHeartbeatTimer; +let flowHeartbeatBusy = false; +let rawFlowScreenshot; +const flowConsoleDiagnostics = []; + const targetUrl = withCanvasRenderer(process.env.VERIFY_URL ?? 'http://localhost:5173/'); const allowCombatAssetFallback = Number( new URL(targetUrl).searchParams.get('debugCombatAssetWatchdogMs') @@ -132,6 +201,37 @@ async function clickBattleBounds(page, bounds, options) { await clickSceneBounds(page, 'BattleScene', bounds, options); } +async function clickBattleResultContinue(page) { + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + const action = battle?.resultActions?.find((candidate) => candidate.kind === 'continue'); + return battle?.resultVisible === true && action?.interactive === true && action?.bounds; + }, undefined, { timeout: 30000 }); + + let resultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + let continueAction = resultState?.resultActions?.find((action) => action.kind === 'continue'); + if (!continueAction?.bounds) { + throw new Error(`Expected an interactive battle-result continue action: ${JSON.stringify(resultState?.resultActions)}`); + } + + if (resultState?.resultSettlement?.status !== 'complete') { + await clickBattleBounds(page, continueAction.bounds); + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + const action = battle?.resultActions?.find((candidate) => candidate.kind === 'continue'); + return battle?.resultSettlement?.status === 'complete' && action?.interactive === true; + }, undefined, { timeout: 30000 }); + await page.waitForTimeout(500); + resultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + continueAction = resultState?.resultActions?.find((action) => action.kind === 'continue'); + if (!continueAction?.bounds) { + throw new Error(`Expected the completed result to retain its continue action: ${JSON.stringify(resultState?.resultActions)}`); + } + } + + await clickBattleBounds(page, continueAction.bounds); +} + function boundsOverlap(left, right) { if (!left || !right) { return false; @@ -311,30 +411,48 @@ function installLegacyUiClickScaling(page) { let serverProcess; let browser; +let page; +const pageErrors = []; try { assertNoLegacyNumericRewardLabels(); + initializeFlowProgress(); serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: true }); - const page = await browser.newPage(desktopBrowserContextOptions); + page = await browser.newPage(desktopBrowserContextOptions); page.setDefaultTimeout(90000); - const pageErrors = []; page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message)); + page.on('console', (message) => { + if (['warning', 'error'].includes(message.type())) { + pushFlowDiagnostic(`${message.type()}: ${message.text()}`); + } + }); + page.on('requestfailed', (request) => { + pushFlowDiagnostic(`requestfailed: ${safeFlowUrlPath(request.url())} ${request.failure()?.errorText ?? ''}`); + }); installLegacyUiClickScaling(page); + installFlowScreenshotPolicy(page); await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); - await page.evaluate(() => window.localStorage.clear()); - await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); - await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 }); - await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined, 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 }); + startFlowHeartbeat(page); + await beginFlowSegment(page, requestedFlowSegment ?? campaignFlowSegments[0]); + if (!segmentedFlowRun || requestedFlowSegment.index === 0) { + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); + await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 }); + await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined, 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 }); + } else { + await restorePreviousFlowCheckpoint(page, requestedFlowSegment); + } + if (shouldRunFlowSegment('yellow-turban')) { const debugBeforeBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); if (!debugBeforeBattle.includes('TitleScene')) { throw new Error(`Expected TitleScene before starting. Active scenes: ${debugBeforeBattle.join(', ')}`); @@ -1105,10 +1223,11 @@ try { const continueAction = window.__HEROS_DEBUG__?.battle()?.resultActions?.find((action) => action.kind === 'continue'); return continueAction?.interactive === true; }); + await page.waitForTimeout(500); await page.screenshot({ path: 'dist/verification-battle-result-victory.png', fullPage: true }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampAfterBattleResult(page, { preserveArrivalReward: true }); await page.screenshot({ path: 'dist/verification-camp.png', fullPage: true }); @@ -1363,7 +1482,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const campaignSaveAfterSecondBattle = await page.evaluate(() => { @@ -1431,7 +1550,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const campaignSaveAfterThirdBattle = await page.evaluate(() => { @@ -1498,7 +1617,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const campaignSaveAfterFourthBattle = await page.evaluate(() => { @@ -1514,6 +1633,10 @@ try { throw new Error(`Expected campaign save to persist fourth battle victory: ${JSON.stringify(campaignSaveAfterFourthBattle)}`); } + await completeFlowSegment(page, 'yellow-turban'); + } + + if (shouldRunFlowSegment('anti-dong')) { await page.mouse.click(1120, 38); await page.waitForTimeout(160); const fourthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -1565,7 +1688,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const campaignSaveAfterFifthBattle = await page.evaluate(() => { @@ -1642,7 +1765,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const campaignSaveAfterSixthBattle = await page.evaluate(() => { @@ -1668,6 +1791,10 @@ try { throw new Error(`Expected sixth camp to expose Xu Province bridge dialogue set: ${JSON.stringify(sixthCampState)}`); } + await completeFlowSegment(page, 'anti-dong'); + } + + if (shouldRunFlowSegment('xuzhou')) { await page.mouse.click(1120, 38); await page.waitForTimeout(160); const sixthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -1753,7 +1880,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const campaignSaveAfterSeventhBattle = await page.evaluate(() => { @@ -1812,7 +1939,7 @@ try { await page.waitForTimeout(320); } if (pageErrors.length > 0) { - throw new Error(`Unexpected runtime error before the fifty-fifth battle: ${JSON.stringify(pageErrors.slice(-5))}`); + throw new Error(`Unexpected runtime error before the eighth battle: ${JSON.stringify(pageErrors.slice(-5))}`); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); @@ -1847,7 +1974,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const eighthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -1926,7 +2053,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const ninthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -1941,6 +2068,10 @@ try { throw new Error(`Expected ninth camp to expose Xuzhou gate dialogue set and preserve recruits: ${JSON.stringify(ninthCampState)}`); } + await completeFlowSegment(page, 'xuzhou'); + } + + if (shouldRunFlowSegment('wandering')) { await page.mouse.click(1120, 38); await page.waitForTimeout(160); const ninthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -2005,7 +2136,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const tenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -2077,7 +2208,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const eleventhCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -2150,7 +2281,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const twelfthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -2232,7 +2363,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const thirteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -2313,7 +2444,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fourteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -2386,7 +2517,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fifteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -2401,6 +2532,10 @@ try { throw new Error(`Expected fifteenth camp to expose Yuan refuge dialogue set, preserve expanded roster, and recruit Zhao Yun: ${JSON.stringify(fifteenthCampState)}`); } + await completeFlowSegment(page, 'wandering'); + } + + if (shouldRunFlowSegment('wolong')) { await openSortiePrep(page); const fifteenthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -2486,7 +2621,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const sixteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -2595,7 +2730,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const seventeenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -2721,7 +2856,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const eighteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -2774,6 +2909,10 @@ try { } await page.screenshot({ path: 'dist/verification-post-bowang-progress.png', fullPage: true }); + await completeFlowSegment(page, 'wolong'); + } + + if (shouldRunFlowSegment('red-cliffs')) { await page.mouse.click(1120, 38); await page.waitForTimeout(180); const changbanSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -2872,7 +3011,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const nineteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -3005,7 +3144,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const twentiethCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -3140,7 +3279,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const twentyFirstCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -3275,7 +3414,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const twentySecondCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -3310,6 +3449,10 @@ try { } await page.screenshot({ path: 'dist/verification-post-redcliffs-fire-progress.png', fullPage: true }); + await completeFlowSegment(page, 'red-cliffs'); + } + + if (shouldRunFlowSegment('jing-yi')) { await page.mouse.click(1120, 38); await page.waitForTimeout(180); const jingzhouSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -3413,7 +3556,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const twentyThirdCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -3552,7 +3695,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const twentyFourthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -3692,7 +3835,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const twentyFifthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -3838,7 +3981,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const twentySixthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -3980,7 +4123,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const twentySeventhCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -4124,7 +4267,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const twentyEighthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -4272,7 +4415,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const twentyNinthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -4414,7 +4557,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const thirtiethCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -4565,7 +4708,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const thirtyFirstCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -4713,7 +4856,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const thirtySecondCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -4862,7 +5005,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const thirtyThirdCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -5016,7 +5159,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const thirtyFourthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -5049,6 +5192,10 @@ try { } await page.screenshot({ path: 'dist/verification-post-jiameng-progress.png', fullPage: true }); + await completeFlowSegment(page, 'jing-yi'); + } + + if (shouldRunFlowSegment('hanzhong-shuhan')) { await page.mouse.click(1120, 38); await page.waitForTimeout(180); const yangpingSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -5167,7 +5314,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const thirtyFifthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -5324,7 +5471,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const thirtySixthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -5485,7 +5632,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const thirtySeventhCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -5625,6 +5772,10 @@ try { } await page.screenshot({ path: 'dist/verification-post-foundation-progress.png', fullPage: true }); + await completeFlowSegment(page, 'hanzhong-shuhan'); + } + + if (shouldRunFlowSegment('jingzhou-crisis')) { await page.mouse.click(1120, 38); await page.waitForTimeout(180); const jingDefenseSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -5741,7 +5892,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const thirtyEighthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -5889,7 +6040,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const thirtyNinthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -6037,7 +6188,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fortiethCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -6217,7 +6368,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fortyFirstCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -6370,7 +6521,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fortySecondCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -6524,7 +6675,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fortyThirdCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -6677,7 +6828,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fortyFourthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -6708,6 +6859,10 @@ try { } await page.screenshot({ path: 'dist/verification-post-maicheng-progress.png', fullPage: true }); + await completeFlowSegment(page, 'jingzhou-crisis'); + } + + if (shouldRunFlowSegment('yiling-baidi')) { await page.mouse.click(1120, 38); await page.waitForTimeout(180); const yilingSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -6829,7 +6984,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fortyFifthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -6982,7 +7137,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fortySixthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -7072,6 +7227,10 @@ try { } await page.screenshot({ path: 'dist/verification-post-baidi-progress.png', fullPage: true }); + await completeFlowSegment(page, 'yiling-baidi'); + } + + if (shouldRunFlowSegment('nanzhong')) { await page.mouse.click(1120, 38); await page.waitForTimeout(180); const nanzhongSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -7197,7 +7356,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fortySeventhCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -7354,7 +7513,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fortyEighthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -7511,7 +7670,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fortyNinthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -7671,7 +7830,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fiftiethCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -7832,7 +7991,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fiftyFirstCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -7993,7 +8152,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fiftySecondCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -8153,7 +8312,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fiftyThirdCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -8313,7 +8472,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fiftyFourthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -8413,6 +8572,10 @@ try { } await page.screenshot({ path: 'dist/verification-post-northern-prep-progress.png', fullPage: true }); + await completeFlowSegment(page, 'nanzhong'); + } + + if (shouldRunFlowSegment('northern')) { await page.mouse.click(1120, 38); await page.waitForTimeout(180); const firstNorthernSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -8534,7 +8697,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fiftyFifthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -8679,7 +8842,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fiftySixthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -8828,7 +8991,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fiftySeventhCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -8974,7 +9137,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fiftyEighthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -9120,7 +9283,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const fiftyNinthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -9272,7 +9435,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const sixtiethCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -9426,7 +9589,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const sixtyFirstCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -9583,7 +9746,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const sixtySecondCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -9763,7 +9926,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const sixtyThirdCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -9948,7 +10111,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const sixtyFourthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -10148,7 +10311,7 @@ try { return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); - await page.mouse.click(738, 642); + await clickBattleResultContinue(page); await waitForCampReady(page); const sixtyFifthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -10447,12 +10610,22 @@ try { throw new Error(`Expected title continue to reopen the ending screen: ${JSON.stringify(titleContinueState)}`); } - console.log(`Verified title-to-ending flow, recruited officer sortie selection, Ma Liang, Yi Ji, Gong Zhi, Huang Zhong, Wei Yan, Pang Tong, Fa Zheng, Wu Yi, Yan Yan, Li Yan, Huang Quan, Ma Chao, Ma Dai, Wang Ping joins, Luofeng, Luo main gate, Mianzhu, Chengdu surrender, Jiameng, Yangping, Dingjun, Hanzhong decisive battle, King of Hanzhong council, Shu-Han foundation state, Jing Province defense vanguard, Fan Castle vanguard, Han River flood, Fan siege, Jing rear crisis, Gongan collapse, Maicheng isolation, Yiling vanguard, Yiling fire attack, Baidi entrustment, Nanzhong stabilization, Meng Huo pacification, second capture, third capture, fourth capture, fifth capture, sixth capture, final capture, northern campaign preparation, Qishan road first northern expedition, Tianshui advance, Jieting crisis, Jiang Wei joining, Qishan retreat, Chencang siege, Wudu/Yinping commanderies, Hanzhong rainy-pass defense, Qishan renewed offensive, Lucheng pursuit, Weishui camps, Weishui north bank pressure, final epilogue, ending-complete save state, and title continue to ending screen at ${targetUrl}`); -} finally { - await browser?.close(); - if (serverProcess && !serverProcess.killed) { - serverProcess.kill(); + await completeFlowSegment(page, 'northern'); } + + if (!segmentedFlowRun) { + console.log(`Verified title-to-ending flow, recruited officer sortie selection, Ma Liang, Yi Ji, Gong Zhi, Huang Zhong, Wei Yan, Pang Tong, Fa Zheng, Wu Yi, Yan Yan, Li Yan, Huang Quan, Ma Chao, Ma Dai, Wang Ping joins, Luofeng, Luo main gate, Mianzhu, Chengdu surrender, Jiameng, Yangping, Dingjun, Hanzhong decisive battle, King of Hanzhong council, Shu-Han foundation state, Jing Province defense vanguard, Fan Castle vanguard, Han River flood, Fan siege, Jing rear crisis, Gongan collapse, Maicheng isolation, Yiling vanguard, Yiling fire attack, Baidi entrustment, Nanzhong stabilization, Meng Huo pacification, second capture, third capture, fourth capture, fifth capture, sixth capture, final capture, northern campaign preparation, Qishan road first northern expedition, Tianshui advance, Jieting crisis, Jiang Wei joining, Qishan retreat, Chencang siege, Wudu/Yinping commanderies, Hanzhong rainy-pass defense, Qishan renewed offensive, Lucheng pursuit, Weishui camps, Weishui north bank pressure, final epilogue, ending-complete save state, and title continue to ending screen at ${targetUrl}`); + } +} catch (error) { + await writeFlowFailureDiagnostics(page, error, pageErrors); + recordFlowProgress('segment-fail', { + message: redactFlowText(error instanceof Error ? error.message : String(error)) + }); + throw error; +} finally { + stopFlowHeartbeat(); + await browser?.close(); + await terminateFlowProcessTree(serverProcess); } async function ensureLocalServer(url) { @@ -10483,10 +10656,61 @@ async function ensureLocalServer(url) { await delay(250); } - child.kill(); + await terminateFlowProcessTree(child); throw new Error(`Vite server did not start at ${url}\n${stderr.join('')}`); } +async function terminateFlowProcessTree(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) { + return; + } + if (process.platform === 'win32' && child.pid) { + await new Promise((resolveTermination) => { + const terminator = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true + }); + let settled = false; + const finish = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolveTermination(); + }; + const timeout = setTimeout(() => { + terminator.kill(); + finish(); + }, 5000); + terminator.once('error', finish); + terminator.once('exit', finish); + }); + } else { + try { + child.kill('SIGTERM'); + } catch { + // The process may have exited between the state check and the signal. + } + } + for (let attempt = 0; attempt < 50 && child.exitCode === null && child.signalCode === null; attempt += 1) { + await delay(100); + } + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill('SIGKILL'); + } catch { + // The process may have exited between the state check and the signal. + } + } + for (let attempt = 0; attempt < 20 && child.exitCode === null && child.signalCode === null; attempt += 1) { + await delay(100); + } + if (child.exitCode === null && child.signalCode === null) { + throw new Error(`Could not terminate flow server process tree for PID ${child.pid ?? 'unknown'}.`); + } +} + async function canReach(url) { try { const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); @@ -10507,6 +10731,7 @@ async function startDeploymentIfNeeded(page, battleId) { } if (state.phase === 'idle') { await page.waitForTimeout(250); + await recordFlowRuntimeProgress(page, 'battle', { battleId }); return; } if (!Array.isArray(state.deployedAllyIds) || state.deployedAllyIds.length === 0) { @@ -10519,6 +10744,7 @@ async function startDeploymentIfNeeded(page, battleId) { return battle?.battleId === expectedBattleId && battle?.phase === 'idle' && battle?.triggeredBattleEvents?.includes('opening'); }, battleId, { timeout: 30000 }); await page.waitForTimeout(250); + await recordFlowRuntimeProgress(page, 'battle', { battleId }); } async function waitForStoryReady(page) { @@ -10532,6 +10758,7 @@ async function waitForStoryReady(page) { storyState?.activeTexture === storyState?.background ); }, undefined, { timeout: 90000 }); + await recordFlowRuntimeProgress(page, 'story'); } async function waitForCampReady(page, options = {}) { @@ -10550,6 +10777,7 @@ async function waitForCampReady(page, options = {}) { if (!options.preserveArrivalReward) { await dismissCampArrivalRewardIfVisible(page); } + await recordFlowRuntimeProgress(page, 'camp'); } async function dismissCampArrivalRewardIfVisible(page) { @@ -10992,3 +11220,481 @@ async function clickReserveTrainingFocus(page, focusId) { await clickLegacyUi(page, selectorX + 12 + index * (buttonWidth + 8) + buttonWidth / 2, staged ? 548 : 655); await page.waitForTimeout(120); } + +function parseFlowCliOptions(args) { + const options = {}; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (!argument.startsWith('--')) { + throw new Error(`Unexpected segmented flow argument: ${argument}`); + } + const option = argument.slice(2); + const equalsIndex = option.indexOf('='); + const key = equalsIndex >= 0 ? option.slice(0, equalsIndex) : option; + const inlineValue = equalsIndex >= 0 ? option.slice(equalsIndex + 1) : undefined; + if (!allowedFlowCliOptions.has(key)) { + throw new Error(`Unknown segmented flow option: --${key}`); + } + if (Object.hasOwn(options, key)) { + throw new Error(`Segmented flow option was provided more than once: --${key}`); + } + if (inlineValue !== undefined) { + if (!inlineValue && !booleanFlowCliOptions.has(key)) { + throw new Error(`Segmented flow option --${key} requires a value.`); + } + options[key] = inlineValue; + continue; + } + const next = args[index + 1]; + if (next && !next.startsWith('--')) { + options[key] = next; + index += 1; + } else if (booleanFlowCliOptions.has(key)) { + options[key] = '1'; + } else { + throw new Error(`Segmented flow option --${key} requires a value.`); + } + } + return options; +} + +function positiveFlowNumber(value, label) { + const number = Math.round(Number(value)); + if (!Number.isFinite(number) || number <= 0) { + throw new Error(`${label} must be a positive number; received ${value}.`); + } + return number; +} + +function flowBooleanOption(value, label) { + if (value === undefined) { + return false; + } + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + throw new Error(`${label} must be a boolean value; received ${value}.`); +} + +function shouldRunFlowSegment(segmentId) { + return !segmentedFlowRun || requestedFlowSegment?.id === segmentId; +} + +function initializeFlowProgress() { + if (!flowProgressPath) { + return; + } + mkdirSync(dirname(flowProgressPath), { recursive: true }); + writeFileSync(flowProgressPath, '', 'utf8'); +} + +function writeFlowTextAtomically(path, contents) { + mkdirSync(dirname(path), { recursive: true }); + const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`; + try { + writeFileSync(temporaryPath, contents, 'utf8'); + renameSync(temporaryPath, path); + } catch (error) { + rmSync(temporaryPath, { force: true }); + throw error; + } +} + +function recordFlowProgress(type, details = {}) { + if (!flowProgressPath) { + return; + } + const event = { + timestamp: new Date().toISOString(), + elapsedMs: Date.now() - flowStartedAt, + type, + segment: activeFlowSegmentId, + ...details + }; + if (type !== 'heartbeat') { + lastFlowProgress = type; + } + const summary = [ + `[verify-flow] ${type}`, + activeFlowSegmentId, + details.battleId, + details.campaignStep, + details.storyPageId + ].filter(Boolean).join(' · '); + console.log(summary); + try { + appendFileSync(flowProgressPath, `${JSON.stringify(event)}\n`, 'utf8'); + } catch (error) { + console.error(`[verify-flow] could not append progress: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function pushFlowDiagnostic(message) { + flowConsoleDiagnostics.push(redactFlowText(message).slice(0, 2000)); + if (flowConsoleDiagnostics.length > 30) { + flowConsoleDiagnostics.splice(0, flowConsoleDiagnostics.length - 30); + } +} + +function redactFlowText(value) { + return String(value) + .replace(/(https?:\/\/[^\s?]+)\?[^\s)]+/gi, '$1?[redacted]') + .replace(/(bearer\s+)[^\s]+/gi, '$1[redacted]') + .replace(/((?:token|password|secret|api[_-]?key)\s*[=:]\s*)[^\s,;]+/gi, '$1[redacted]'); +} + +function safeFlowUrlPath(url) { + try { + const parsed = new URL(url); + return parsed.pathname; + } catch { + return String(url).slice(0, 300); + } +} + +function installFlowScreenshotPolicy(page) { + rawFlowScreenshot = page.screenshot.bind(page); + if (flowScreenshotMode === 'all' && !segmentedFlowRun) { + return; + } + if (flowScreenshotMode === 'all') { + const screenshotDirectory = resolve(dirname(flowProgressPath), 'screenshots'); + mkdirSync(screenshotDirectory, { recursive: true }); + page.screenshot = async (options = {}) => rawFlowScreenshot({ + ...options, + path: resolve(screenshotDirectory, basename(options.path ?? `flow-${Date.now()}.png`)) + }); + return; + } + page.screenshot = async () => Buffer.alloc(0); +} + +async function beginFlowSegment(page, segment) { + if (!segmentedFlowRun) { + return; + } + activeFlowSegmentId = segment.id; + recordFlowProgress('start', { + label: segment.label, + firstBattleOrdinal: segment.firstBattleOrdinal, + lastBattleOrdinal: segment.lastBattleOrdinal, + manifestHash: campaignFlowManifestHash + }); + await recordFlowRuntimeProgress(page, 'segment-start'); +} + +async function completeFlowSegment(page, segmentId) { + if (!segmentedFlowRun) { + return; + } + const segment = campaignFlowSegments.find((candidate) => candidate.id === segmentId); + if (!segment || requestedFlowSegment?.id !== segmentId) { + throw new Error(`Cannot complete unexpected flow segment ${segmentId}.`); + } + await saveFlowCheckpoint(page, segment); + const runtime = await captureFlowRuntimeState(page); + recordFlowProgress('segment-pass', { + campaignStep: runtime?.camp?.campaignStep ?? runtime?.ending?.campaignStep ?? null, + battleId: segment.lastBattleId, + completedBattles: segment.lastBattleOrdinal + }); +} + +function expectedFlowCheckpointStep(segment) { + return segment.checkpointStep; +} + +function assertFlowCampaignPosition(campaign, segment, source) { + const expectedStep = expectedFlowCheckpointStep(segment); + const completedBattleIds = Object.keys(campaign?.battleHistory ?? {}); + const expectedBattleIds = campaignFlowSegments + .slice(0, segment.index + 1) + .flatMap((candidate) => candidate.battleIds); + const expectedBattleIdSet = new Set(expectedBattleIds); + const missingBattleIds = expectedBattleIds.filter((battleId) => !completedBattleIds.includes(battleId)); + const unexpectedBattleIds = completedBattleIds.filter((battleId) => !expectedBattleIdSet.has(battleId)); + const nonVictoryBattleIds = expectedBattleIds.filter( + (battleId) => campaign?.battleHistory?.[battleId]?.outcome !== 'victory' + ); + const exactBattlePrefix = completedBattleIds.length === expectedBattleIds.length && + expectedBattleIds.every((battleId, index) => completedBattleIds[index] === battleId); + if ( + !campaign || + campaign.version !== 1 || + campaign.activeSaveSlot !== 1 || + campaign.step !== expectedStep || + campaign.latestBattleId !== segment.lastBattleId || + completedBattleIds.length !== expectedBattleIds.length || + !exactBattlePrefix || + missingBattleIds.length > 0 || + unexpectedBattleIds.length > 0 || + nonVictoryBattleIds.length > 0 + ) { + throw new Error(`Flow checkpoint ${source} is out of position: ${JSON.stringify({ + segment: segment.id, + expectedStep, + actualStep: campaign?.step ?? null, + expectedLatestBattleId: segment.lastBattleId, + actualLatestBattleId: campaign?.latestBattleId ?? null, + expectedCompletedBattles: expectedBattleIds.length, + actualCompletedBattles: completedBattleIds.length, + campaignVersion: campaign?.version ?? null, + activeSaveSlot: campaign?.activeSaveSlot ?? null, + exactBattlePrefix, + missingBattleIds, + unexpectedBattleIds, + nonVictoryBattleIds + })}`); + } +} + +async function saveFlowCheckpoint(page, segment) { + const localStorage = await readAllFlowLocalStorage(page); + const campaign = parseFlowCampaignStorage(localStorage); + assertFlowCampaignPosition(campaign, segment, 'save'); + mkdirSync(flowCheckpointDirectory, { recursive: true }); + const checkpointPath = resolve(flowCheckpointDirectory, `${segment.id}.json`); + writeFlowTextAtomically(checkpointPath, `${JSON.stringify({ + version: 1, + manifestHash: campaignFlowManifestHash, + savedAt: new Date().toISOString(), + segment: { + id: segment.id, + firstBattleOrdinal: segment.firstBattleOrdinal, + lastBattleOrdinal: segment.lastBattleOrdinal, + lastBattleId: segment.lastBattleId, + checkpointStep: expectedFlowCheckpointStep(segment) + }, + localStorage + }, null, 2)}\n`); + recordFlowProgress('checkpoint', { + campaignStep: expectedFlowCheckpointStep(segment), + battleId: segment.lastBattleId, + completedBattles: segment.lastBattleOrdinal + }); +} + +async function restorePreviousFlowCheckpoint(page, segment) { + const previousSegment = campaignFlowSegments[segment.index - 1]; + if (!previousSegment) { + throw new Error(`Flow segment ${segment.id} does not have a prerequisite checkpoint.`); + } + const checkpointPath = resolve(flowCheckpointDirectory, `${previousSegment.id}.json`); + if (!existsSync(checkpointPath)) { + throw new Error(`Missing prerequisite checkpoint for ${segment.id}: ${checkpointPath}`); + } + const checkpoint = JSON.parse(readFileSync(checkpointPath, 'utf8')); + if ( + checkpoint?.version !== 1 || + checkpoint.manifestHash !== campaignFlowManifestHash || + checkpoint.segment?.id !== previousSegment.id || + !checkpoint.localStorage || + typeof checkpoint.localStorage !== 'object' + ) { + throw new Error(`Invalid prerequisite checkpoint for ${segment.id}: ${checkpointPath}`); + } + const storedCampaign = parseFlowCampaignStorage(checkpoint.localStorage); + assertFlowCampaignPosition(storedCampaign, previousSegment, 'restore file'); + await page.evaluate((entries) => { + window.localStorage.clear(); + for (const [key, value] of Object.entries(entries)) { + window.localStorage.setItem(key, value); + } + }, checkpoint.localStorage); + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); + await waitForFlowRuntimeReady(page, { title: true }); + await page.evaluate(() => window.__HEROS_GAME__?.scene.getScene('TitleScene')?.continueGame?.(1)); + await waitForCampReady(page, { + step: expectedFlowCheckpointStep(previousSegment), + withoutStory: true, + preserveArrivalReward: true + }); + await page.waitForFunction(() => { + const acknowledgement = window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement; + return !acknowledgement?.battleId || acknowledgement.visible === true; + }, undefined, { timeout: 30000 }); + await dismissCampArrivalRewardIfVisible(page); + const restoredCampaign = parseFlowCampaignStorage(await readAllFlowLocalStorage(page)); + assertFlowCampaignPosition(restoredCampaign, previousSegment, 'restored runtime'); + recordFlowProgress('checkpoint-restored', { + campaignStep: expectedFlowCheckpointStep(previousSegment), + battleId: previousSegment.lastBattleId, + completedBattles: previousSegment.lastBattleOrdinal + }); +} + +async function readAllFlowLocalStorage(page) { + return page.evaluate(() => { + const entries = {}; + for (let index = 0; index < window.localStorage.length; index += 1) { + const key = window.localStorage.key(index); + if (key !== null) { + entries[key] = window.localStorage.getItem(key) ?? ''; + } + } + return entries; + }); +} + +function parseFlowCampaignStorage(storage) { + const globalRaw = storage['heros-web:campaign-state']; + const slotRaw = storage['heros-web:campaign-state:slot-1']; + if (!globalRaw || !slotRaw) { + throw new Error('Flow checkpoint must contain matching global and slot-1 campaign saves.'); + } + if (globalRaw !== slotRaw) { + throw new Error('Flow checkpoint global and slot-1 campaign saves do not match.'); + } + try { + return JSON.parse(slotRaw); + } catch (error) { + throw new Error(`Flow checkpoint campaign state is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); + } +} + +async function waitForFlowRuntimeReady(page, options = {}) { + await page.waitForFunction(() => ( + document.querySelector('canvas') !== null && + window.__HEROS_GAME__ !== undefined && + window.__HEROS_DEBUG__ !== undefined + ), undefined, { timeout: 90000 }); + if (options.title) { + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.activeScenes()?.includes('TitleScene') ?? false + ), undefined, { timeout: 90000 }); + } +} + +function startFlowHeartbeat(page) { + if (!flowHeartbeatMs || !flowProgressPath) { + return; + } + flowHeartbeatTimer = setInterval(async () => { + if (flowHeartbeatBusy) { + return; + } + flowHeartbeatBusy = true; + try { + const runtime = await captureFlowRuntimeState(page); + recordFlowProgress('heartbeat', { + lastProgress: lastFlowProgress, + runtime + }); + } finally { + flowHeartbeatBusy = false; + } + }, flowHeartbeatMs); + flowHeartbeatTimer.unref?.(); +} + +function stopFlowHeartbeat() { + if (flowHeartbeatTimer) { + clearInterval(flowHeartbeatTimer); + flowHeartbeatTimer = undefined; + } +} + +async function captureFlowRuntimeState(page) { + if (!page || page.isClosed()) { + return { available: false, reason: 'page-closed' }; + } + try { + const state = await page.evaluate(() => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + const camp = window.__HEROS_DEBUG__?.camp?.(); + const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.(); + const ending = window.__HEROS_DEBUG__?.scene('EndingScene')?.getDebugState?.(); + return { + available: true, + activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + battle: battle ? { + battleId: battle.battleId ?? null, + phase: battle.phase ?? null, + turnNumber: battle.turnNumber ?? null, + battleOutcome: battle.battleOutcome ?? null, + resultVisible: battle.resultVisible ?? false + } : null, + camp: camp ? { + campaignStep: camp.campaign?.step ?? null, + campBattleId: camp.campBattleId ?? null, + activeTab: camp.activeTab ?? null, + sortieVisible: camp.sortieVisible ?? false, + rewardVisible: camp.victoryRewardAcknowledgement?.visible ?? false + } : null, + story: story ? { + currentPageId: story.currentPageId ?? null, + ready: story.ready ?? false, + backgroundReady: story.backgroundReady ?? false + } : null, + ending: ending ? { + campaignStep: ending.campaignStep ?? null, + ready: ending.ready ?? false + } : null, + viewport: { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio + } + }; + }); + return { ...state, url: safeFlowUrlPath(page.url()) }; + } catch (error) { + return { + available: false, + reason: error instanceof Error ? error.message : String(error), + url: safeFlowUrlPath(page.url()) + }; + } +} + +async function recordFlowRuntimeProgress(page, type, details = {}) { + if (!flowProgressPath) { + return; + } + const runtime = await captureFlowRuntimeState(page); + recordFlowProgress(type, { + battleId: runtime?.battle?.battleId ?? details.battleId ?? null, + campaignStep: runtime?.camp?.campaignStep ?? null, + storyPageId: runtime?.story?.currentPageId ?? null, + ...details, + runtime + }); +} + +async function writeFlowFailureDiagnostics(page, error, pageErrors) { + if (!segmentedFlowRun && !flowProgressPath) { + return; + } + const failureDirectory = flowProgressPath + ? dirname(flowProgressPath) + : resolve('dist/verify-flow', activeFlowSegmentId); + try { + mkdirSync(failureDirectory, { recursive: true }); + const runtime = await captureFlowRuntimeState(page); + writeFlowTextAtomically(resolve(failureDirectory, 'failure-state.json'), `${JSON.stringify({ + version: 1, + manifestHash: campaignFlowManifestHash, + timestamp: new Date().toISOString(), + elapsedMs: Date.now() - flowStartedAt, + segment: activeFlowSegmentId, + lastProgress: lastFlowProgress, + error: error instanceof Error + ? { name: error.name, message: redactFlowText(error.message), stack: redactFlowText(error.stack ?? '') } + : { message: redactFlowText(error) }, + runtime, + pageErrors: pageErrors.slice(-10).map(redactFlowText), + browserDiagnostics: flowConsoleDiagnostics.slice(-20) + }, null, 2)}\n`); + if (flowScreenshotMode !== 'none' && rawFlowScreenshot && page && !page.isClosed()) { + await rawFlowScreenshot({ path: resolve(failureDirectory, 'failure.png'), fullPage: true }); + } + } catch (diagnosticError) { + console.error( + `[verify-flow] failure diagnostics could not be written: ${diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError)}` + ); + } +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 4512842..382b7fb 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -6389,7 +6389,8 @@ try { gold: 9800, turnNumber: 18, defeatedEnemies: 20, - selectedSortieUnitIds: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'ma-dai', 'huang-quan', 'li-yan', 'wei-yan'] + selectedSortieUnitIds: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'ma-dai', 'huang-quan', 'li-yan', 'wei-yan'], + pendingVictoryRewardNotice: true }); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); @@ -6397,6 +6398,11 @@ try { await waitForCamp(page); await waitForCampSkinTransition(page, 'northern'); await waitForCampSoundscapeTransition(page, 'camp-frontier', 'mountain-wind-ambience'); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === true, + undefined, + { timeout: 30000 } + ); 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)}`); assertCampSkinState(finalCampState, 'northern', 'final camp'); @@ -6420,29 +6426,61 @@ try { Array.isArray(finalCampState.sortieDeploymentPreview) && finalCampState.sortieDeploymentPreview.length === 0, `Expected final camp deployment preview to stay empty: ${JSON.stringify(finalCampState?.sortieDeploymentPreview)}` ); - if (finalCampState.victoryRewardAcknowledgement?.visible === true) { - const finalCampRewardSaveBeforeClose = await readCampaignSave(page); - const finalCampRewardClosePoint = await readCampVictoryRewardControlPoint(page, 'close'); - await page.mouse.click(finalCampRewardClosePoint.x, finalCampRewardClosePoint.y); - await page.waitForFunction( - () => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === false, - undefined, - { timeout: 30000 } - ); - const finalCampAfterRewardClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); - const finalCampRewardSaveAfterClose = await readCampaignSave(page); - assert( - sameJsonValue(finalCampRewardSaveAfterClose, finalCampRewardSaveBeforeClose) && - finalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true && - finalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true, - `Expected closing the final-camp reward notice to preserve its destination-specific NEW states: ${JSON.stringify({ - saveUnchanged: sameJsonValue(finalCampRewardSaveAfterClose, finalCampRewardSaveBeforeClose), - tabs: finalCampAfterRewardClose?.campTabs - ?.filter((tab) => ['equipment', 'supplies'].includes(tab.id)) - .map((tab) => ({ id: tab.id, new: tab.newBadgeVisible })) - })}` - ); - } + assert( + finalCampState.victoryRewardAcknowledgement?.visible === true && + finalCampState.victoryRewardAcknowledgement?.battleId === 'sixty-sixth-battle-wuzhang-final', + `Expected the seeded final victory to expose its undismissed reward notice: ${JSON.stringify(finalCampState.victoryRewardAcknowledgement)}` + ); + const finalCampRewardSaveBeforeClose = await readCampaignSave(page); + const finalCampRewardClosePoint = await readCampVictoryRewardControlPoint(page, 'close'); + await page.mouse.click(finalCampRewardClosePoint.x, finalCampRewardClosePoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === false, + undefined, + { timeout: 30000 } + ); + const finalCampAfterRewardClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const finalCampRewardSaveAfterClose = await readCampaignSave(page); + const finalBattleId = 'sixty-sixth-battle-wuzhang-final'; + assert( + sameJsonValue( + campaignSaveWithoutVictoryRewardNoticeDismissals(finalCampRewardSaveAfterClose), + campaignSaveWithoutVictoryRewardNoticeDismissals(finalCampRewardSaveBeforeClose) + ) && + sameJsonValue( + campaignVictoryAcknowledgementSnapshot(finalCampRewardSaveAfterClose, finalBattleId), + campaignVictoryAcknowledgementSnapshot(finalCampRewardSaveBeforeClose, finalBattleId) + ) && + campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId).current === false && + campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId).slot1 === false && + campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveAfterClose, finalBattleId).current === true && + campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveAfterClose, finalBattleId).slot1 === true && + finalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true && + finalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true, + `Expected closing the final-camp reward notice to persist only its dismissal while preserving destination-specific NEW states: ${JSON.stringify({ + beforeDismissal: campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId), + afterDismissal: campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveAfterClose, finalBattleId), + beforeAcknowledgement: campaignVictoryAcknowledgementSnapshot(finalCampRewardSaveBeforeClose, finalBattleId), + afterAcknowledgement: campaignVictoryAcknowledgementSnapshot(finalCampRewardSaveAfterClose, finalBattleId), + tabs: finalCampAfterRewardClose?.campTabs + ?.filter((tab) => ['equipment', 'supplies'].includes(tab.id)) + .map((tab) => ({ id: tab.id, new: tab.newBadgeVisible })) + })}` + ); + + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForTitle(page); + await clickLegacyUi(page, 962, 310); + await waitForCamp(page); + await waitForCampSkinTransition(page, 'northern'); + const resumedFinalCampAfterRewardClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + assert( + resumedFinalCampAfterRewardClose?.campaign?.step === 'sixty-sixth-camp' && + resumedFinalCampAfterRewardClose?.victoryRewardAcknowledgement?.visible === false && + resumedFinalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true && + resumedFinalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true, + `Expected title continue to keep the dismissed reward notice closed without consuming NEW states: ${JSON.stringify(resumedFinalCampAfterRewardClose)}` + ); await captureStableCampScreenshot(page, `${screenshotDir}/rc-final-camp.png`, 0, 'final camp'); await assertFinalCampRosterPagination(page); @@ -9946,6 +9984,19 @@ async function seedCampaignSave(page, options) { reserveTraining: [], completedAt: now }; + const pendingVictoryRewardNotice = seed.pendingVictoryRewardNotice === true; + const acknowledgedVictoryRewardCategories = { + ...(current.acknowledgedVictoryRewardCategories ?? {}) + }; + if (pendingVictoryRewardNotice) { + delete acknowledgedVictoryRewardCategories[seed.battleId]; + } + const previouslyRecordedVictoryBattleIds = Object.entries(current.battleHistory ?? {}) + .filter(([, settlement]) => settlement?.outcome === 'victory') + .map(([battleId]) => battleId); + if (current.firstBattleReport?.outcome === 'victory') { + previouslyRecordedVictoryBattleIds.push(current.firstBattleReport.battleId); + } const next = { ...current, updatedAt: now, @@ -9956,9 +10007,18 @@ async function seedCampaignSave(page, options) { selectedSortieUnitIds: seed.selectedSortieUnitIds, latestBattleId: seed.battleId, firstBattleReport: report, - acknowledgedVictoryRewardBattleIds: [ - ...new Set([...(current.acknowledgedVictoryRewardBattleIds ?? []), seed.battleId]) - ], + acknowledgedVictoryRewardBattleIds: pendingVictoryRewardNotice + ? (current.acknowledgedVictoryRewardBattleIds ?? []).filter((battleId) => battleId !== seed.battleId) + : [...new Set([...(current.acknowledgedVictoryRewardBattleIds ?? []), seed.battleId])], + acknowledgedVictoryRewardCategories, + dismissedVictoryRewardNoticeBattleIds: pendingVictoryRewardNotice + ? [ + ...new Set([ + ...(current.dismissedVictoryRewardNoticeBattleIds ?? []), + ...previouslyRecordedVictoryBattleIds.filter((battleId) => battleId !== seed.battleId) + ]) + ].filter((battleId) => battleId !== seed.battleId) + : [...(current.dismissedVictoryRewardNoticeBattleIds ?? [])], battleHistory: { ...(current.battleHistory ?? {}), [seed.battleId]: settlement @@ -10259,6 +10319,32 @@ function campaignVictoryAcknowledgementSnapshot(save, battleId) { }; } +function campaignVictoryNoticeDismissalSnapshot(save, battleId) { + const stateSnapshot = (state) => (state?.dismissedVictoryRewardNoticeBattleIds ?? []).includes(battleId); + return { + current: stateSnapshot(save?.current), + slot1: stateSnapshot(save?.slot1) + }; +} + +function campaignSaveWithoutVictoryRewardNoticeDismissals(save) { + const stableState = (state) => { + if (!state) { + return state; + } + const { + updatedAt: _updatedAt, + dismissedVictoryRewardNoticeBattleIds: _dismissedVictoryRewardNoticeBattleIds, + ...stableFields + } = state; + return stableFields; + }; + return { + current: stableState(save?.current), + slot1: stableState(save?.slot1) + }; +} + function recommendationFeedbackSemantic(feedback) { if (!feedback) { return null; diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index efa3f35..b6f7526 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; const checks = [ 'scripts/verify-desktop-browser-viewport.mjs', + 'scripts/verify-flow-segment-data.mjs', 'scripts/verify-campaign-flow-data.mjs', 'scripts/verify-campaign-save-normalization.mjs', 'scripts/verify-campaign-completion.mjs', diff --git a/scripts/verify-victory-reward-acknowledgements.mjs b/scripts/verify-victory-reward-acknowledgements.mjs index d04a14a..e6300a2 100644 --- a/scripts/verify-victory-reward-acknowledgements.mjs +++ b/scripts/verify-victory-reward-acknowledgements.mjs @@ -29,9 +29,11 @@ try { acknowledgeCampaignVictoryReward, campaignStorageKey, campaignVictoryRewardCategoriesFor, + dismissCampaignVictoryRewardNotice, getCampaignState, getPendingCampaignVictoryRewardBattleIds, getPendingCampaignVictoryRewardCategories, + getPendingCampaignVictoryRewardNoticeReport, getPendingCampaignVictoryRewardReport, loadCampaignState, resetCampaignState, @@ -73,10 +75,27 @@ try { const expectedCategories = ['gold', 'equipment', 'supplies', 'reputation', 'recruits', 'unlocks']; assert( JSON.stringify(campaignVictoryRewardCategoriesFor(report)) === JSON.stringify(expectedCategories) && - JSON.stringify(getPendingCampaignVictoryRewardCategories(scenario.id)) === JSON.stringify(expectedCategories), + JSON.stringify(getPendingCampaignVictoryRewardCategories(scenario.id)) === JSON.stringify(expectedCategories) && + getPendingCampaignVictoryRewardNoticeReport()?.battleId === scenario.id, `Every populated reward category must begin pending in stable display order: ${JSON.stringify(getPendingCampaignVictoryRewardCategories(scenario.id))}` ); + const dismissedNotice = dismissCampaignVictoryRewardNotice(scenario.id); + const repeatedNoticeDismissal = dismissCampaignVictoryRewardNotice(scenario.id); + const persistedDismissedNotice = JSON.parse(values.get(campaignStorageKey)); + const persistedDismissedNoticeSlot = JSON.parse(values.get(`${campaignStorageKey}:slot-1`)); + loadCampaignState(); + assert( + JSON.stringify(dismissedNotice.dismissedVictoryRewardNoticeBattleIds) === JSON.stringify([scenario.id]) && + JSON.stringify(repeatedNoticeDismissal.dismissedVictoryRewardNoticeBattleIds) === JSON.stringify([scenario.id]) && + persistedDismissedNotice.dismissedVictoryRewardNoticeBattleIds.includes(scenario.id) && + persistedDismissedNoticeSlot.dismissedVictoryRewardNoticeBattleIds.includes(scenario.id) && + JSON.stringify(getPendingCampaignVictoryRewardCategories(scenario.id)) === JSON.stringify(expectedCategories) && + getPendingCampaignVictoryRewardReport()?.battleId === scenario.id && + getPendingCampaignVictoryRewardNoticeReport() === undefined, + `Dismissing a victory notice must persist once without consuming destination-specific NEW categories: ${JSON.stringify({ dismissedNotice, persistedDismissedNotice })}` + ); + const equipmentOnly = acknowledgeCampaignVictoryReward(scenario.id, ['equipment']); assert( JSON.stringify(equipmentOnly.acknowledgedVictoryRewardCategories[scenario.id]) === JSON.stringify(['equipment']) && @@ -107,13 +126,15 @@ try { const legacySave = structuredClone(persistedFullState); delete legacySave.acknowledgedVictoryRewardCategories; + delete legacySave.dismissedVictoryRewardNoticeBattleIds; values.set(campaignStorageKey, JSON.stringify(legacySave)); const migratedLegacySave = loadCampaignState(); assert( migratedLegacySave.acknowledgedVictoryRewardBattleIds.includes(scenario.id) && JSON.stringify(migratedLegacySave.acknowledgedVictoryRewardCategories[scenario.id]) === JSON.stringify(expectedCategories) && + JSON.stringify(migratedLegacySave.dismissedVictoryRewardNoticeBattleIds) === JSON.stringify([]) && getPendingCampaignVictoryRewardReport() === undefined, - `A legacy all-or-nothing acknowledgement must migrate to every actual reward category: ${JSON.stringify(migratedLegacySave)}` + `A legacy all-or-nothing acknowledgement must migrate safely without requiring a notice-dismissal field: ${JSON.stringify(migratedLegacySave)}` ); const partialSave = structuredClone(persistedFullState); @@ -122,13 +143,21 @@ try { [scenario.id]: ['equipment', 'unknown-category'], 'second-battle-yellow-turban-pursuit': ['equipment'] }; + partialSave.dismissedVictoryRewardNoticeBattleIds = [ + scenario.id, + scenario.id, + 'second-battle-yellow-turban-pursuit', + 'unknown-battle' + ]; values.set(campaignStorageKey, JSON.stringify(partialSave)); const normalizedPartialSave = loadCampaignState(); assert( JSON.stringify(normalizedPartialSave.acknowledgedVictoryRewardCategories) === JSON.stringify({ [scenario.id]: ['equipment'] }) && !normalizedPartialSave.acknowledgedVictoryRewardBattleIds.includes(scenario.id) && - getPendingCampaignVictoryRewardCategories(scenario.id).includes('supplies'), - `Normalization must retain valid partial progress while dropping unknown categories and non-victory battles: ${JSON.stringify(normalizedPartialSave.acknowledgedVictoryRewardCategories)}` + JSON.stringify(normalizedPartialSave.dismissedVictoryRewardNoticeBattleIds) === JSON.stringify([scenario.id]) && + getPendingCampaignVictoryRewardCategories(scenario.id).includes('supplies') && + getPendingCampaignVictoryRewardNoticeReport() === undefined, + `Normalization must retain a valid dismissal while dropping duplicate, unknown, and non-victory battle ids: ${JSON.stringify(normalizedPartialSave)}` ); const repeatedAll = acknowledgeCampaignVictoryReward(scenario.id); @@ -164,11 +193,19 @@ try { setFirstBattleReport(secondReport); assert( getPendingCampaignVictoryRewardReport()?.battleId === secondScenario.id && + getPendingCampaignVictoryRewardNoticeReport()?.battleId === secondScenario.id && JSON.stringify(getPendingCampaignVictoryRewardBattleIds()) === JSON.stringify([scenario.id, secondScenario.id]) && getPendingCampaignVictoryRewardCategories().includes('equipment') && getPendingCampaignVictoryRewardCategories().includes('supplies'), 'A newer victory must not hide unreviewed reward categories from an earlier settlement.' ); + dismissCampaignVictoryRewardNotice(secondScenario.id); + assert( + getPendingCampaignVictoryRewardReport()?.battleId === secondScenario.id && + getPendingCampaignVictoryRewardNoticeReport() === undefined && + JSON.stringify(getPendingCampaignVictoryRewardCategories(secondScenario.id)) === JSON.stringify(['gold', 'supplies']), + 'Dismissing the latest notice must preserve all NEW categories without resurfacing an older reward as another arrival modal.' + ); acknowledgeCampaignVictoryReward(secondScenario.id); assert( getPendingCampaignVictoryRewardReport()?.battleId === scenario.id && @@ -176,7 +213,7 @@ try { 'After reviewing the latest victory, the next pending reward report and aggregated NEW category must resurface.' ); - console.log('Verified category-level victory reward acknowledgements, cross-victory pending visibility, destination isolation, persistence, and legacy migration.'); + console.log('Verified category-level victory reward acknowledgements, persisted notice dismissal, cross-victory visibility, normalization, and legacy migration.'); } finally { await server.close(); } diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 539b21b..6090a78 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -172,11 +172,12 @@ import { campaignSortiePresetIds, campaignReserveTrainingFocusDefinitions, defaultCampaignReserveTrainingFocusId, + dismissCampaignVictoryRewardNotice, ensureCampaignRosterUnits, getCampaignState, getFirstBattleReport, getPendingCampaignVictoryRewardBattleIds, - getPendingCampaignVictoryRewardReport, + getPendingCampaignVictoryRewardNoticeReport, getPendingCampaignVictoryRewardCategories, markCampaignStep, listCampaignSaveSlots, @@ -11476,7 +11477,7 @@ export class CampScene extends Phaser.Scene { this.visitedTabs = new Set(['status']); this.campaign = getCampaignState(); this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport(); - this.pendingVictoryRewardReport = getPendingCampaignVictoryRewardReport(); + this.pendingVictoryRewardReport = getPendingCampaignVictoryRewardNoticeReport(); if (!this.retrySortieBattleId) { this.ensureCurrentCampRecruitment(); } @@ -13058,7 +13059,7 @@ export class CampScene extends Phaser.Scene { } private showVictoryRewardAcknowledgement() { - let report = this.pendingVictoryRewardReport ?? getPendingCampaignVictoryRewardReport(); + let report = this.pendingVictoryRewardReport ?? getPendingCampaignVictoryRewardNoticeReport(); if (!report || report.outcome !== 'victory' || this.sortieObjects.length > 0) { return; } @@ -13071,7 +13072,7 @@ export class CampScene extends Phaser.Scene { this.campaign = acknowledgeCampaignVictoryReward(battleId, passiveCategories); } }); - report = getPendingCampaignVictoryRewardReport(); + report = getPendingCampaignVictoryRewardNoticeReport(); if (!report) { this.pendingVictoryRewardReport = undefined; return; @@ -13366,6 +13367,10 @@ export class CampScene extends Phaser.Scene { this.focusVictoryRewardEquipment(report); } this.acknowledgePendingVictoryRewardCategories(categories); + if (report) { + this.campaign = dismissCampaignVictoryRewardNotice(report.battleId); + this.pendingVictoryRewardReport = getPendingCampaignVictoryRewardNoticeReport(); + } soundDirector.playSelect(); this.hideVictoryRewardAcknowledgement(); this.updateTabButtons(); @@ -13409,7 +13414,7 @@ export class CampScene extends Phaser.Scene { this.campaign = acknowledgeCampaignVictoryReward(battleId, relevantCategories); } }); - this.pendingVictoryRewardReport = getPendingCampaignVictoryRewardReport(); + this.pendingVictoryRewardReport = getPendingCampaignVictoryRewardNoticeReport(); } private showRequestedSortiePrep() { diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index bd38e49..b9878b5 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -487,6 +487,7 @@ export type CampaignState = { completedCampVisits: string[]; acknowledgedVictoryRewardBattleIds: string[]; acknowledgedVictoryRewardCategories: CampaignVictoryRewardAcknowledgements; + dismissedVictoryRewardNoticeBattleIds: string[]; battleHistory: Record; latestBattleId?: string; firstBattleReport?: FirstBattleReport; @@ -694,6 +695,7 @@ export function createInitialCampaignState(): CampaignState { completedCampVisits: [], acknowledgedVictoryRewardBattleIds: [], acknowledgedVictoryRewardCategories: {}, + dismissedVictoryRewardNoticeBattleIds: [], battleHistory: {} }; } @@ -1157,6 +1159,46 @@ export function getPendingCampaignVictoryRewardReport() { return cloneCampaignVictoryRewardReport(report); } +export function getPendingCampaignVictoryRewardNoticeReport() { + const state = ensureCampaignState(); + const battleId = state.latestBattleId; + if ( + !battleId || + state.dismissedVictoryRewardNoticeBattleIds.includes(battleId) || + pendingCampaignVictoryRewardCategoriesFor(state, battleId).length === 0 + ) { + return undefined; + } + const report = battleId ? campaignVictoryRewardSourceFor(state, battleId) : undefined; + if (!report || report.outcome !== 'victory') { + return undefined; + } + return cloneCampaignVictoryRewardReport(report); +} + +export function dismissCampaignVictoryRewardNotice(battleId: string) { + const state = ensureCampaignState(); + const normalizedBattleId = normalizeKeyString(battleId); + const report = campaignVictoryRewardSourceFor(state, normalizedBattleId); + if ( + !normalizedBattleId || + !(normalizedBattleId in battleScenarios) || + !report || + !isCampaignVictory(report) || + state.dismissedVictoryRewardNoticeBattleIds.includes(normalizedBattleId) + ) { + return cloneCampaignState(state); + } + + state.dismissedVictoryRewardNoticeBattleIds = [ + ...state.dismissedVictoryRewardNoticeBattleIds, + normalizedBattleId + ]; + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + return cloneCampaignState(state); +} + export function acknowledgeCampaignVictoryReward( battleId: string, requestedCategories?: readonly CampaignVictoryRewardCategoryId[] @@ -1508,6 +1550,8 @@ function normalizeCampaignState(state: Partial): CampaignState { normalized.acknowledgedVictoryRewardCategories = normalizeCampaignVictoryRewardAcknowledgements( normalized.acknowledgedVictoryRewardCategories ); + normalized.dismissedVictoryRewardNoticeBattleIds = uniqueStrings(normalized.dismissedVictoryRewardNoticeBattleIds) + .filter((battleId) => battleId in battleScenarios); normalized.inventory = normalizeInventory(normalized.inventory); normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory); normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport); @@ -1577,6 +1621,8 @@ function normalizeCampaignState(state: Partial): CampaignState { } normalized.acknowledgedVictoryRewardBattleIds = normalized.acknowledgedVictoryRewardBattleIds .filter((battleId) => recordedVictoryBattleIds.has(battleId)); + normalized.dismissedVictoryRewardNoticeBattleIds = normalized.dismissedVictoryRewardNoticeBattleIds + .filter((battleId) => recordedVictoryBattleIds.has(battleId)); const acknowledgedVictoryRewardCategories = Object.entries(normalized.acknowledgedVictoryRewardCategories) .reduce((acknowledgements, [battleId, categories]) => { if (!recordedVictoryBattleIds.has(battleId)) {