Files
KateLegend2_proj/assets/scripts/common/PerfMonitor.ts
T
2026-05-06 08:17:32 +08:00

106 lines
3.9 KiB
TypeScript

import { Logger, MetricAggregate } from './Logger';
import {
PERF_TOUCH_RESPONSE_MAX_MS,
PERF_JUMP_STATE_TOGGLE_MAX_MS,
PERF_COMBO_RECOGNITION_MAX_MS,
PERF_PARABOLIC_ANGLE_ACCURACY_TARGET,
PERF_AIR_JUMP_BLOCK_RATE_TARGET,
MAX_FIRST_PACKAGE_BYTES,
MAX_AUDIO_BUNDLE_BYTES,
MAX_MEMORY_PEAK_BYTES,
} from './Constants';
/**
* Performance monitor (task 10.2, req 18 & 20).
*
* Aggregates all the KPI samples recorded through `Logger.metric(...)` and
* reports pass/fail against every threshold listed in the requirements doc.
* CI can run `collectReport()` and assert that `allPassing === true`.
*/
export interface IPerfThreshold {
metric: string;
/** Budget target (max for latency, min for rates). */
limit: number;
comparator: '<=' | '>=';
requirementId: string;
}
export const CORE_PERF_THRESHOLDS: ReadonlyArray<IPerfThreshold> = [
{ metric: 'input/touchStart', limit: PERF_TOUCH_RESPONSE_MAX_MS, comparator: '<=', requirementId: 'req 20.1' },
{ metric: 'jump/state_toggle_ms', limit: PERF_JUMP_STATE_TOGGLE_MAX_MS, comparator: '<=', requirementId: 'req 20.2' },
{ metric: 'input/parabolic_accuracy', limit: PERF_PARABOLIC_ANGLE_ACCURACY_TARGET, comparator: '>=', requirementId: 'req 20.3' },
{ metric: 'input/combo_recognition_ms', limit: PERF_COMBO_RECOGNITION_MAX_MS, comparator: '<=', requirementId: 'req 20.4' },
{ metric: 'jump/air_jump_block_rate', limit: PERF_AIR_JUMP_BLOCK_RATE_TARGET, comparator: '>=', requirementId: 'req 20.5' },
];
export interface IPerfCheckResult {
threshold: IPerfThreshold;
aggregate?: MetricAggregate;
passing: boolean;
reason: string;
}
export interface IPerfReport {
allPassing: boolean;
checks: IPerfCheckResult[];
/** Optional build/runtime sizes filled in by CI (bytes). */
firstPackageBytes?: number;
audioBundleBytes?: number;
memoryPeakBytes?: number;
/** Top-level pass/fail for the size budgets. */
sizeBudgetPassing?: boolean;
}
export class PerfMonitor {
constructor(
private readonly logger: Logger,
private readonly thresholds: ReadonlyArray<IPerfThreshold> = CORE_PERF_THRESHOLDS
) {}
public collectReport(buildSizes?: {
firstPackageBytes?: number;
audioBundleBytes?: number;
memoryPeakBytes?: number;
}): IPerfReport {
const checks: IPerfCheckResult[] = this.thresholds.map((t) => this.check(t));
let sizeBudgetPassing: boolean | undefined;
if (buildSizes) {
sizeBudgetPassing =
(buildSizes.firstPackageBytes ?? 0) <= MAX_FIRST_PACKAGE_BYTES &&
(buildSizes.audioBundleBytes ?? 0) <= MAX_AUDIO_BUNDLE_BYTES &&
(buildSizes.memoryPeakBytes ?? 0) <= MAX_MEMORY_PEAK_BYTES;
}
const allPassing = checks.every((c) => c.passing) && (sizeBudgetPassing ?? true);
return {
allPassing,
checks,
firstPackageBytes: buildSizes?.firstPackageBytes,
audioBundleBytes: buildSizes?.audioBundleBytes,
memoryPeakBytes: buildSizes?.memoryPeakBytes,
sizeBudgetPassing,
};
}
private check(t: IPerfThreshold): IPerfCheckResult {
const agg = this.logger.aggregate(t.metric);
if (!agg) {
return {
threshold: t,
passing: false,
reason: `no samples recorded for "${t.metric}"`,
};
}
// For latency, use p95. For rate metrics, use avg.
const isRate = t.comparator === '>=';
const observed = isRate ? agg.avg : agg.p95;
const passing = isRate ? observed >= t.limit : observed <= t.limit;
return {
threshold: t,
aggregate: agg,
passing,
reason: `${t.metric} ${isRate ? 'avg' : 'p95'}=${observed.toFixed(2)} vs limit ${t.limit} (${t.comparator})`,
};
}
}