fix: harden campaign resume UX and flow verification
This commit is contained in:
@@ -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",
|
||||
|
||||
136
scripts/campaign-flow-segments.mjs
Normal file
136
scripts/campaign-flow-segments.mjs
Normal 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}.`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
63
scripts/verify-flow-segment-data.mjs
Normal file
63
scripts/verify-flow-segment-data.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
585
scripts/verify-flow-segments.mjs
Normal file
585
scripts/verify-flow-segments.mjs
Normal 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
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user