fix: harden campaign resume UX and flow verification

This commit is contained in:
2026-07-21 09:59:48 +09:00
parent be5fe3e880
commit c810f95d00
11 changed files with 1815 additions and 125 deletions

View File

@@ -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",

View File

@@ -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}.`);
}
}

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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));
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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',

View File

@@ -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();
}

View File

@@ -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() {

View File

@@ -487,6 +487,7 @@ export type CampaignState = {
completedCampVisits: string[];
acknowledgedVictoryRewardBattleIds: string[];
acknowledgedVictoryRewardCategories: CampaignVictoryRewardAcknowledgements;
dismissedVictoryRewardNoticeBattleIds: string[];
battleHistory: Record<string, CampaignBattleSettlement>;
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>): 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>): 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<CampaignVictoryRewardAcknowledgements>((acknowledgements, [battleId, categories]) => {
if (!recordedVictoryBattleIds.has(battleId)) {