338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
export const requirementKinds = [
|
||
'round',
|
||
'matches',
|
||
'populated-minutes',
|
||
'player-hours',
|
||
'population',
|
||
'sustained-population',
|
||
'healthy-minutes',
|
||
'failure-budget',
|
||
'recovery',
|
||
'manual'
|
||
] as const;
|
||
export type RequirementKind = (typeof requirementKinds)[number];
|
||
export interface BetaRequirement {
|
||
id: string;
|
||
kind: RequirementKind;
|
||
target: number;
|
||
label: string;
|
||
minRound: number;
|
||
minPlayers: number;
|
||
minMinutes: number;
|
||
}
|
||
export interface BetaPlan {
|
||
version: 1;
|
||
requirements: BetaRequirement[];
|
||
scope: 'all' | 'quorum' | 'map' | 'game' | 'map-game';
|
||
quorum: number;
|
||
timeoutHours: number;
|
||
failureAction: 'fail' | 'pause' | 'rollback';
|
||
rollbackRestart: 'manual' | 'when-empty';
|
||
waveSize: number; // 0 deploys all selected servers; otherwise pass each wave before expanding.
|
||
}
|
||
export const requirementLabels: Record<RequirementKind, string> = {
|
||
round: 'Reach round',
|
||
matches: 'Complete qualifying matches',
|
||
'populated-minutes': 'Populated minutes',
|
||
'player-hours': 'Player-hours',
|
||
population: 'Reach player count',
|
||
'sustained-population': 'Sustained population (minutes)',
|
||
'healthy-minutes': 'Continuous healthy minutes',
|
||
'failure-budget': 'Maximum health outages',
|
||
recovery: 'Successful recovery cycles',
|
||
manual: 'Tester checklist'
|
||
};
|
||
export function requirement(kind: RequirementKind, id: string): BetaRequirement {
|
||
return {
|
||
id,
|
||
kind,
|
||
label: requirementLabels[kind],
|
||
target: kind === 'round' ? 25 : kind === 'failure-budget' ? 0 : 1,
|
||
minRound: 0,
|
||
minPlayers: 1,
|
||
minMinutes: 0
|
||
};
|
||
}
|
||
export function defaultPlan(): BetaPlan {
|
||
return {
|
||
version: 1,
|
||
requirements: [requirement('healthy-minutes', 'health')],
|
||
scope: 'all',
|
||
quorum: 1,
|
||
timeoutHours: 24,
|
||
failureAction: 'fail',
|
||
rollbackRestart: 'when-empty',
|
||
waveSize: 0
|
||
};
|
||
}
|
||
export const builtInPlans: Array<{ name: string; plan: BetaPlan }> = [
|
||
{ name: 'Quick smoke test', plan: defaultPlan() },
|
||
{
|
||
name: 'Gameplay validation',
|
||
plan: {
|
||
...defaultPlan(),
|
||
requirements: [
|
||
{ ...requirement('matches', 'matches'), target: 2, minRound: 25 },
|
||
{ ...requirement('populated-minutes', 'playtime'), target: 90 },
|
||
requirement('failure-budget', 'outages')
|
||
]
|
||
}
|
||
},
|
||
{
|
||
name: 'Long soak',
|
||
plan: {
|
||
...defaultPlan(),
|
||
timeoutHours: 72,
|
||
requirements: [
|
||
{ ...requirement('healthy-minutes', 'health'), target: 360 },
|
||
{ ...requirement('player-hours', 'participation'), target: 10 },
|
||
requirement('failure-budget', 'outages')
|
||
]
|
||
}
|
||
}
|
||
];
|
||
|
||
/** Strict allow-list validation at every boundary (including stored presets). */
|
||
export function parseBetaPlan(value: unknown): BetaPlan {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||
throw new Error('Test plan must be an object');
|
||
const p = value as BetaPlan;
|
||
if (p.version !== 1 || !['all', 'quorum', 'map', 'game', 'map-game'].includes(p.scope))
|
||
throw new Error('Invalid test plan version or scope');
|
||
if (
|
||
!['fail', 'pause', 'rollback'].includes(p.failureAction) ||
|
||
!['manual', 'when-empty'].includes(p.rollbackRestart)
|
||
)
|
||
throw new Error('Invalid failure policy');
|
||
for (const [key, min, max] of [
|
||
['quorum', 1, 1000],
|
||
['timeoutHours', 0.01, 8760],
|
||
['waveSize', 0, 1000]
|
||
] as const) {
|
||
if (typeof p[key] !== 'number' || !Number.isFinite(p[key]) || p[key] < min || p[key] > max)
|
||
throw new Error(`Invalid ${key}`);
|
||
}
|
||
if (!Number.isInteger(p.quorum) || !Number.isInteger(p.waveSize))
|
||
throw new Error('Quorum and wave size must be integers');
|
||
if (!Array.isArray(p.requirements) || !p.requirements.length || p.requirements.length > 30)
|
||
throw new Error('Choose 1–30 requirements');
|
||
const ids = new Set<string>();
|
||
const requirements = p.requirements.map((r) => {
|
||
if (!r || typeof r !== 'object' || !requirementKinds.includes(r.kind))
|
||
throw new Error('Unknown requirement');
|
||
if (
|
||
typeof r.id !== 'string' ||
|
||
!/^[a-zA-Z0-9_-]{1,64}$/.test(r.id) ||
|
||
r.id in Object.prototype ||
|
||
ids.has(r.id)
|
||
)
|
||
throw new Error('Requirement IDs must be unique, non-reserved identifiers');
|
||
ids.add(r.id);
|
||
if (typeof r.label !== 'string' || !r.label.trim() || r.label.length > 200)
|
||
throw new Error('Requirement needs a label (up to 200 characters)');
|
||
for (const key of ['target', 'minRound', 'minPlayers', 'minMinutes'] as const) {
|
||
if (
|
||
typeof r[key] !== 'number' ||
|
||
!Number.isFinite(r[key]) ||
|
||
r[key] < 0 ||
|
||
r[key] > 1_000_000
|
||
)
|
||
throw new Error(`Invalid requirement ${key}`);
|
||
}
|
||
if (r.kind !== 'failure-budget' && r.target <= 0)
|
||
throw new Error('Requirement target must be positive');
|
||
if (
|
||
['round', 'matches', 'population', 'failure-budget', 'recovery', 'manual'].includes(
|
||
r.kind
|
||
) &&
|
||
!Number.isInteger(r.target)
|
||
)
|
||
throw new Error('Count targets must be integers');
|
||
if (!Number.isInteger(r.minRound) || !Number.isInteger(r.minPlayers))
|
||
throw new Error('Round and player qualifiers must be integers');
|
||
if (r.kind === 'manual' && r.target !== 1)
|
||
throw new Error('Add a separate requirement for each checklist item');
|
||
return {
|
||
id: r.id,
|
||
kind: r.kind,
|
||
label: r.label.trim(),
|
||
target: r.target,
|
||
minRound: r.minRound,
|
||
minPlayers: r.minPlayers,
|
||
minMinutes: r.minMinutes
|
||
};
|
||
});
|
||
return {
|
||
version: 1,
|
||
requirements,
|
||
scope: p.scope,
|
||
quorum: p.quorum,
|
||
timeoutHours: p.timeoutHours,
|
||
failureAction: p.failureAction,
|
||
rollbackRestart: p.rollbackRestart,
|
||
waveSize: p.waveSize
|
||
};
|
||
}
|
||
|
||
export interface RequirementProgress {
|
||
id: string;
|
||
label: string;
|
||
current: number;
|
||
target: number;
|
||
passed: boolean;
|
||
failed: boolean;
|
||
detail: string;
|
||
}
|
||
export interface BetaTestView {
|
||
plan: BetaPlan;
|
||
outcome: string;
|
||
reason: string | null;
|
||
servers: Array<{
|
||
server: string;
|
||
server_deleted_at: string | null;
|
||
phase: number;
|
||
passed: boolean;
|
||
progress: RequirementProgress[];
|
||
observingAt?: string | null;
|
||
observation?: {
|
||
lastGameplayAt: string | null;
|
||
lastEvent: string | null;
|
||
players: number | null;
|
||
round: number | null;
|
||
matchStarted: boolean;
|
||
gameEnded: boolean;
|
||
healthy: boolean | null;
|
||
healthCheckedAt: string | null;
|
||
};
|
||
}>;
|
||
evidence: Array<{
|
||
id: number;
|
||
server: string;
|
||
server_deleted_at: string | null;
|
||
received_at: string;
|
||
kind: string;
|
||
payload: string;
|
||
}>;
|
||
}
|
||
|
||
export interface BetaPlanIssue {
|
||
field: string;
|
||
message: string;
|
||
requirementId?: string;
|
||
}
|
||
export function betaPlanIssues(plan: BetaPlan, serverCount?: number): BetaPlanIssue[] {
|
||
const issues: BetaPlanIssue[] = [];
|
||
const check = (
|
||
field: string,
|
||
value: number,
|
||
min: number,
|
||
max: number,
|
||
integer: boolean,
|
||
message: string,
|
||
requirementId?: string
|
||
) => {
|
||
if (
|
||
typeof value !== 'number' ||
|
||
!Number.isFinite(value) ||
|
||
value < min ||
|
||
value > max ||
|
||
(integer && !Number.isInteger(value))
|
||
)
|
||
issues.push({ field, message, requirementId });
|
||
};
|
||
check(
|
||
'timeoutHours',
|
||
plan.timeoutHours,
|
||
0.01,
|
||
8760,
|
||
false,
|
||
'Enter a testing window between 0.01 and 8,760 hours.'
|
||
);
|
||
check(
|
||
'waveSize',
|
||
plan.waveSize,
|
||
0,
|
||
1000,
|
||
true,
|
||
'Enter a whole number from 0 to 1,000; 0 deploys to all selected servers.'
|
||
);
|
||
if (plan.scope === 'quorum')
|
||
check(
|
||
'quorum',
|
||
plan.quorum,
|
||
1,
|
||
serverCount ?? 1000,
|
||
true,
|
||
`Choose between 1 and ${serverCount ?? 1000} qualifying servers.`
|
||
);
|
||
if (!plan.requirements.length)
|
||
issues.push({
|
||
field: 'requirements',
|
||
message: 'Add at least one requirement to continue.'
|
||
});
|
||
for (const r of plan.requirements) {
|
||
if (!r.label?.trim())
|
||
issues.push({
|
||
field: `${r.id}-label`,
|
||
message: 'Describe what this requirement checks.',
|
||
requirementId: r.id
|
||
});
|
||
const integral = [
|
||
'round',
|
||
'matches',
|
||
'population',
|
||
'failure-budget',
|
||
'recovery',
|
||
'manual'
|
||
].includes(r.kind);
|
||
check(
|
||
`${r.id}-target`,
|
||
r.target,
|
||
r.kind === 'failure-budget' ? 0 : 0.000001,
|
||
1_000_000,
|
||
integral,
|
||
`Enter ${integral ? 'a whole number' : 'a number'} ${r.kind === 'failure-budget' ? 'of zero or more' : 'greater than zero'} for ${requirementLabels[r.kind].toLowerCase()}.`,
|
||
r.id
|
||
);
|
||
if (
|
||
[
|
||
'round',
|
||
'matches',
|
||
'populated-minutes',
|
||
'player-hours',
|
||
'sustained-population'
|
||
].includes(r.kind)
|
||
)
|
||
check(
|
||
`${r.id}-minPlayers`,
|
||
r.minPlayers,
|
||
1,
|
||
1_000_000,
|
||
true,
|
||
'Enter at least one player, using a whole number.',
|
||
r.id
|
||
);
|
||
if (r.kind === 'matches') {
|
||
check(
|
||
`${r.id}-minRound`,
|
||
r.minRound,
|
||
0,
|
||
1_000_000,
|
||
true,
|
||
'Enter a whole round number; 0 allows any round.',
|
||
r.id
|
||
);
|
||
check(
|
||
`${r.id}-minMinutes`,
|
||
r.minMinutes,
|
||
0,
|
||
1_000_000,
|
||
false,
|
||
'Enter a duration of zero or more minutes.',
|
||
r.id
|
||
);
|
||
}
|
||
}
|
||
return issues;
|
||
}
|