Support multi-game betas with automatic server selection
This commit is contained in:
@@ -1,9 +1,42 @@
|
|||||||
# Beta test plans
|
# Beta test plans
|
||||||
|
|
||||||
Create a plan in Betas, choose its servers, and deploy. The server's game script
|
Create a plan in Betas, choose how many instances per game must pass, and deploy. The server's game script
|
||||||
reports gameplay to Electric Cherry. The same reports update Fleet and any active
|
reports gameplay to Electric Cherry. The same reports update Fleet and any active
|
||||||
beta on that server.
|
beta on that server.
|
||||||
|
|
||||||
|
## Files and automatic server selection
|
||||||
|
|
||||||
|
A beta can bundle files for different games, including T5 and T6 together. Each
|
||||||
|
file installs only on participants matching its game or map scope; an unscoped
|
||||||
|
file applies to all participants. Every file needs at least one matching test
|
||||||
|
participant. For example, a T5 script and a T6 script can share one beta without
|
||||||
|
either script being installed on the other game's servers.
|
||||||
|
|
||||||
|
Setup defaults to automatic selection with one instance per applicable game.
|
||||||
|
Choose the number that must pass for each game. Every automatically selected
|
||||||
|
instance must pass every requirement in the plan. Set a game's count to zero to
|
||||||
|
omit it only when every selected file still has a matching participant.
|
||||||
|
|
||||||
|
When you choose **Start beta**, Electric Cherry chooses compatible servers with
|
||||||
|
the fewest current players, while ensuring that every file is tested. Counts
|
||||||
|
are exact: requesting two T5 and three T6 instances selects five participants,
|
||||||
|
and all five must pass. Server choices are made at launch, so review shows the
|
||||||
|
requested counts rather than a provisional list of server names.
|
||||||
|
|
||||||
|
Automatic selection requires fresh player observations and available running
|
||||||
|
servers. Servers held by another active beta or with pending deployment work
|
||||||
|
are excluded, as are instances on unavailable, draining, or disabled hosts.
|
||||||
|
Unknown or stale player counts are not treated as empty. If the
|
||||||
|
requested counts cannot be met, including coverage of every selected map-scoped
|
||||||
|
file, the beta does not start. Deployment and restarts still wait for confirmed
|
||||||
|
empty lobbies and restart capacity.
|
||||||
|
|
||||||
|
Open **Advanced options → Choose specific servers** for manual selection. The
|
||||||
|
picker includes servers matching any file in the bundle; each file must have a
|
||||||
|
matching selected server. Manual selection retains the configurable coverage
|
||||||
|
rules below. Drafts preserve the selection mode and counts, and older drafts
|
||||||
|
with explicit server choices resume in manual mode.
|
||||||
|
|
||||||
## Game reporter protocol
|
## Game reporter protocol
|
||||||
|
|
||||||
Set `EC_TELEMETRY_TOKEN` on the Electric Cherry controller to a dedicated random
|
Set `EC_TELEMETRY_TOKEN` on the Electric Cherry controller to a dedicated random
|
||||||
@@ -209,9 +242,11 @@ Health intervals spanning more than 60 seconds earn no healthy time.
|
|||||||
|
|
||||||
## Coverage, waves and outcomes
|
## Coverage, waves and outcomes
|
||||||
|
|
||||||
Coverage can require all selected servers, a quorum, one per selected map,
|
Automatic selection requires all requested instances to pass. With manual server
|
||||||
|
selection, coverage can require all selected servers, a quorum, one per selected map,
|
||||||
one per selected game, or one per selected game/map combination. Coverage
|
one per selected game, or one per selected game/map combination. Coverage
|
||||||
metadata is pinned when the beta opens.
|
metadata is pinned when the beta opens. Every bundled file also needs at least
|
||||||
|
one passing server that received it; a quorum cannot skip testing a game's file.
|
||||||
|
|
||||||
Set wave size to 1 for a canary, a larger number for batches, or 0 for all
|
Set wave size to 1 for a canary, a larger number for batches, or 0 for all
|
||||||
selected servers. Later waves receive files after the previous waves pass.
|
selected servers. Later waves receive files after the previous waves pass.
|
||||||
@@ -288,7 +323,13 @@ changing the policy still requires a new beta.
|
|||||||
|
|
||||||
## API and storage
|
## API and storage
|
||||||
|
|
||||||
`POST /api/betas` accepts `testPlan`.
|
`POST /api/betas` accepts `scripts`, `testPlan`, and either
|
||||||
|
`instancesPerGame` (for example, `{ "t5": 2, "t6": 3 }`) for automatic selection
|
||||||
|
or `servers` with explicit registered server names for manual selection. Submit
|
||||||
|
only positive integer counts in `instancesPerGame`; omit games set to zero in
|
||||||
|
the UI. Automatic selection sets the plan's coverage `scope` to `all`.
|
||||||
|
Do not send both selection forms. File scopes determine which bundle files each
|
||||||
|
participant receives in both modes.
|
||||||
`GET /api/betas/:id` includes the plan, outcome, per-server progress, and
|
`GET /api/betas/:id` includes the plan, outcome, per-server progress, and
|
||||||
recent evidence under `test`.
|
recent evidence under `test`.
|
||||||
|
|
||||||
|
|||||||
+33
-1
@@ -5,6 +5,8 @@ export interface BetaDraft {
|
|||||||
version: 1; savedAt: string; step: number;
|
version: 1; savedAt: string; step: number;
|
||||||
picks: Array<{ scriptId: number | null; versionId: number | null }>;
|
picks: Array<{ scriptId: number | null; versionId: number | null }>;
|
||||||
servers: string[]; testPlan: BetaPlan;
|
servers: string[]; testPlan: BetaPlan;
|
||||||
|
selectionMode: 'automatic' | 'manual';
|
||||||
|
instancesPerGame: Record<string, number | null>;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
/** Validate draft structure, preserving incomplete numeric fields for correction. */
|
/** Validate draft structure, preserving incomplete numeric fields for correction. */
|
||||||
@@ -17,10 +19,40 @@ export function readBetaDraft(raw: string | null): BetaDraft | null {
|
|||||||
|| !Array.isArray(d.servers) || !d.servers.every((s: unknown) => typeof s === 'string')
|
|| !Array.isArray(d.servers) || !d.servers.every((s: unknown) => typeof s === 'string')
|
||||||
|| d.testPlan?.version !== 1 || !Array.isArray(d.testPlan.requirements) || d.testPlan.requirements.length > 30
|
|| d.testPlan?.version !== 1 || !Array.isArray(d.testPlan.requirements) || d.testPlan.requirements.length > 30
|
||||||
|| !d.testPlan.requirements.every((r: BetaPlan['requirements'][number]) => r && requirementKinds.includes(r.kind) && typeof r.id === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(r.id) && typeof r.label === 'string')) return null;
|
|| !d.testPlan.requirements.every((r: BetaPlan['requirements'][number]) => r && requirementKinds.includes(r.kind) && typeof r.id === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(r.id) && typeof r.label === 'string')) return null;
|
||||||
return { ...d, step: Math.max(0, Math.min(3, Number(d.step) || 0)) };
|
if (d.selectionMode !== undefined && !['automatic', 'manual'].includes(d.selectionMode)) return null;
|
||||||
|
if (d.instancesPerGame !== undefined && (!d.instancesPerGame || typeof d.instancesPerGame !== 'object'
|
||||||
|
|| Array.isArray(d.instancesPerGame) || !Object.entries(d.instancesPerGame).every(([game, count]) =>
|
||||||
|
['t5', 't6'].includes(game) && (count === null || typeof count === 'number' && Number.isFinite(count))))) return null;
|
||||||
|
// Existing drafts represent explicit selections and must not silently switch modes.
|
||||||
|
return { ...d, selectionMode: d.selectionMode ?? (d.servers.length ? 'manual' : 'automatic'),
|
||||||
|
instancesPerGame: d.instancesPerGame ?? {}, step: Math.max(0, Math.min(3, Number(d.step) || 0)) };
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BetaFileScope { scope_type: 'game' | 'map' | null; scope_value: string | null }
|
||||||
|
interface BetaServerScope { name: string; game: string | null; map: string | null }
|
||||||
|
|
||||||
|
export function betaScopeMatches(file: BetaFileScope, server: BetaServerScope): boolean {
|
||||||
|
return !file.scope_type || (file.scope_type === 'game' ? server.game : server.map) === file.scope_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bundle installs each file only on its own matching participants. */
|
||||||
|
export function betaCandidateServers<T extends BetaServerScope>(files: readonly BetaFileScope[], servers: readonly T[]): T[] {
|
||||||
|
return servers.filter(server => files.some(file => betaScopeMatches(file, server)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function betaApplicableGames(files: readonly BetaFileScope[], servers: readonly BetaServerScope[]): string[] {
|
||||||
|
return [...new Set([
|
||||||
|
...files.filter(file => file.scope_type === 'game').map(file => file.scope_value),
|
||||||
|
...betaCandidateServers(files, servers).map(server => server.game)
|
||||||
|
].filter((game): game is string => game === 't5' || game === 't6'))].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep incomplete counts recoverable and default newly applicable games to one. */
|
||||||
|
export function betaInstanceCounts(games: readonly string[], saved: Record<string, number | null>): Record<string, number | null> {
|
||||||
|
return Object.fromEntries(games.map(game => [game, saved[game] === undefined ? 1 : saved[game]]));
|
||||||
|
}
|
||||||
|
|
||||||
export function compatibleBetaServers(selected: Iterable<string>, candidates: readonly string[]): { kept: Set<string>; removed: string[] } {
|
export function compatibleBetaServers(selected: Iterable<string>, candidates: readonly string[]): { kept: Set<string>; removed: string[] } {
|
||||||
const allowed = new Set(candidates), names = [...selected];
|
const allowed = new Set(candidates), names = [...selected];
|
||||||
return { kept: new Set(names.filter(name => allowed.has(name))), removed: names.filter(name => !allowed.has(name)) };
|
return { kept: new Set(names.filter(name => allowed.has(name))), removed: names.filter(name => !allowed.has(name)) };
|
||||||
|
|||||||
@@ -125,9 +125,8 @@ function migrateTable(handle: Database.Database, table: string, cols: Array<{ na
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Backfill the multi-script join tables from the legacy single-script columns.
|
// Backfill the multi-script join tables from the legacy single-script columns.
|
||||||
// Idempotent — every INSERT is guarded by a NOT EXISTS clause on the target
|
// Existing bundle membership is authoritative. Only single-script betas can
|
||||||
// row's primary key. Safe to run on every boot even after the deprecated
|
// inherit server membership from the deprecated primary-script columns.
|
||||||
// columns are eventually dropped.
|
|
||||||
function backfillMultiScriptJoins(handle: Database.Database): void {
|
function backfillMultiScriptJoins(handle: Database.Database): void {
|
||||||
handle.exec(
|
handle.exec(
|
||||||
`INSERT INTO beta_scripts (beta_id, script_id, script_version_id)
|
`INSERT INTO beta_scripts (beta_id, script_id, script_version_id)
|
||||||
@@ -144,9 +143,11 @@ function backfillMultiScriptJoins(handle: Database.Database): void {
|
|||||||
WHERE b.script_id IS NOT NULL
|
WHERE b.script_id IS NOT NULL
|
||||||
AND bs.server_deleted_at IS NULL
|
AND bs.server_deleted_at IS NULL
|
||||||
AND EXISTS (SELECT 1 FROM servers WHERE name = bs.server)
|
AND EXISTS (SELECT 1 FROM servers WHERE name = bs.server)
|
||||||
|
AND (SELECT COUNT(*) FROM beta_scripts x WHERE x.beta_id = b.id) = 1
|
||||||
|
AND EXISTS (SELECT 1 FROM beta_scripts x WHERE x.beta_id = b.id AND x.script_id = b.script_id)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM beta_server_scripts x
|
SELECT 1 FROM beta_server_scripts x
|
||||||
WHERE x.beta_id = bs.beta_id AND x.server = bs.server AND x.script_id = b.script_id
|
WHERE x.beta_id = bs.beta_id AND x.server = bs.server
|
||||||
)`
|
)`
|
||||||
);
|
);
|
||||||
handle.exec(
|
handle.exec(
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { listHosts } from '../hosts/index.js';
|
||||||
|
import { protectedWork } from '../autoscaling/snapshot.js';
|
||||||
|
import { freshStatusTimestamp } from '../../server-freshness.js';
|
||||||
|
import { listServers, type ServerRow } from './servers.js';
|
||||||
|
import { observationReader } from './status.js';
|
||||||
|
import type { ScriptRow } from './scripts.js';
|
||||||
|
|
||||||
|
const games = ['t5', 't6'] as const;
|
||||||
|
type Game = typeof games[number];
|
||||||
|
type Counts = Partial<Record<Game, number>>;
|
||||||
|
|
||||||
|
export function parseBetaInstanceCounts(value: unknown): Counts {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value) || !Object.keys(value).length) {
|
||||||
|
throw new Error('instancesPerGame must contain at least one game and instance count');
|
||||||
|
}
|
||||||
|
const counts: Counts = {};
|
||||||
|
for (const [game, count] of Object.entries(value)) {
|
||||||
|
if (!games.includes(game as Game) || !Number.isSafeInteger(count) || count < 1 || count > 1000) {
|
||||||
|
throw new Error('Instance counts must be integers from 1 to 1000 for t5 or t6');
|
||||||
|
}
|
||||||
|
counts[game as Game] = count;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(script: ScriptRow, server: ServerRow): boolean {
|
||||||
|
return !script.scope_type || !script.scope_value
|
||||||
|
|| (script.scope_type === 'game' ? server.game : server.map) === script.scope_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choose at submission time using the same fresh observations shown in Fleet. */
|
||||||
|
export function selectBetaServers(scripts: ScriptRow[], instancesPerGame: unknown): string[] {
|
||||||
|
const counts = parseBetaInstanceCounts(instancesPerGame), observe = observationReader(), now = Date.now();
|
||||||
|
const hosts = new Map(listHosts().map(host => [host.id, host]));
|
||||||
|
const busy = new Set((db().prepare(`SELECT server FROM server_deployments WHERE server_deleted_at IS NULL
|
||||||
|
AND (state IN ('queued','waiting','running','awaiting-health','interrupted')
|
||||||
|
OR (action IN ('destroy','rebuild') AND state = 'failed'
|
||||||
|
AND stage IN ('Stopping and removing container','Removing server files and registry')))`)
|
||||||
|
.all() as { server: string }[]).map(row => row.server));
|
||||||
|
const candidates = listServers().flatMap(server => {
|
||||||
|
if (!counts[server.game as Game] || server.desired_state === 'stopped' || busy.has(server.name)
|
||||||
|
|| !scripts.some(script => matches(script, server)) || protectedWork(server.name)) return [];
|
||||||
|
const host = server.host_id ? hosts.get(server.host_id) : null;
|
||||||
|
if (server.host_id && (!host?.connected || host.mode !== 'active' || host.decommission)) return [];
|
||||||
|
const observation = observe(server);
|
||||||
|
if (observation.health !== 'online' || observation.last_players === null
|
||||||
|
|| !freshStatusTimestamp(observation.last_seen_at, now)) return [];
|
||||||
|
// An occupied RCON observation must not rank as empty just because a
|
||||||
|
// competing telemetry report claims zero players.
|
||||||
|
const rconPlayers = freshStatusTimestamp(server.last_seen_at, now)
|
||||||
|
&& (!observation.telemetry_after || Date.parse(server.last_seen_at!) > Date.parse(observation.telemetry_after))
|
||||||
|
? server.last_players ?? 0 : 0;
|
||||||
|
return [{ server, players: Math.max(observation.last_players, rconPlayers) }];
|
||||||
|
}).sort((a, b) => a.players - b.players || a.server.name.localeCompare(b.server.name));
|
||||||
|
|
||||||
|
for (const game of games) {
|
||||||
|
const available = candidates.filter(candidate => candidate.server.game === game).length;
|
||||||
|
if ((counts[game] ?? 0) > available) {
|
||||||
|
throw new Error(`${game.toUpperCase()} needs ${counts[game]} available instances, but only ${available} have fresh status and no pending work. Reduce the count or retry when more servers are available.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const script of scripts) {
|
||||||
|
if (!candidates.some(candidate => matches(script, candidate.server))) {
|
||||||
|
throw new Error(`${script.name}: no available server in the requested games matches this file's scope.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map scopes need a representative even when the least populated servers
|
||||||
|
// all run the same map. Reserve enough remaining slots for every map.
|
||||||
|
const requiredMaps = new Set(scripts.filter(script => script.scope_type === 'map').map(script => script.scope_value!));
|
||||||
|
function canComplete(remaining: typeof candidates, slots: Counts, maps: Set<string>): boolean {
|
||||||
|
const options = new Map<string, Set<Game>>();
|
||||||
|
for (const game of games) {
|
||||||
|
if (remaining.filter(candidate => candidate.server.game === game).length < (slots[game] ?? 0)) return false;
|
||||||
|
}
|
||||||
|
for (const map of maps) {
|
||||||
|
const choices = new Set(remaining.filter(candidate => candidate.server.map === map && (slots[candidate.server.game as Game] ?? 0) > 0)
|
||||||
|
.map(candidate => candidate.server.game as Game));
|
||||||
|
if (!choices.size) return false;
|
||||||
|
options.set(map, choices);
|
||||||
|
}
|
||||||
|
// There are two supported games: single-game maps reserve that game's
|
||||||
|
// slots, and maps available in either game use any remaining slot.
|
||||||
|
return games.every(game => [...options.values()].filter(choices => choices.size === 1 && choices.has(game)).length <= (slots[game] ?? 0))
|
||||||
|
&& maps.size <= games.reduce((total, game) => total + (slots[game] ?? 0), 0);
|
||||||
|
}
|
||||||
|
if (!canComplete(candidates, counts, requiredMaps)) {
|
||||||
|
throw new Error('Increase the instance counts to cover every selected file\'s map scope, or choose servers in advanced options.');
|
||||||
|
}
|
||||||
|
const selected: string[] = [], slots = { ...counts };
|
||||||
|
let maps = requiredMaps;
|
||||||
|
for (let index = 0; index < candidates.length; index++) {
|
||||||
|
const { server } = candidates[index], game = server.game as Game;
|
||||||
|
if (!slots[game]) continue;
|
||||||
|
const nextSlots = { ...slots, [game]: slots[game]! - 1 }, nextMaps = new Set(maps);
|
||||||
|
if (server.map) nextMaps.delete(server.map);
|
||||||
|
if (!canComplete(candidates.slice(index + 1), nextSlots, nextMaps)) continue;
|
||||||
|
selected.push(server.name); slots[game] = nextSlots[game]; maps = nextMaps;
|
||||||
|
if (games.every(game => !slots[game])) break;
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
+90
-112
@@ -1,6 +1,7 @@
|
|||||||
import { putFileBlob, blobContent } from '../db/file-blobs.js';
|
import { putFileBlob, blobContent } from '../db/file-blobs.js';
|
||||||
import { getVersionContent } from './scripts.js';
|
import { getVersionContent } from './scripts.js';
|
||||||
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
|
import { defaultPlan, parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
|
||||||
|
import { selectBetaServers } from './beta-selection.js';
|
||||||
import { storedPlan, testView, activity, setOutcome } from './beta-tests.js';
|
import { storedPlan, testView, activity, setOutcome } from './beta-tests.js';
|
||||||
import { archiveBetaRevision, currentBetaRevision } from './beta-revisions.js';
|
import { archiveBetaRevision, currentBetaRevision } from './beta-revisions.js';
|
||||||
import type { BetaPromotionPreview } from '../../beta-promotion.js';
|
import type { BetaPromotionPreview } from '../../beta-promotion.js';
|
||||||
@@ -74,7 +75,7 @@ function scopeAppliesToServer(
|
|||||||
scope: { scope_type: string | null; scope_value: string | null },
|
scope: { scope_type: string | null; scope_value: string | null },
|
||||||
server: { game: string | null; map: string | null }
|
server: { game: string | null; map: string | null }
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!scope.scope_type || !scope.scope_value) return false;
|
if (!scope.scope_type || !scope.scope_value) return true;
|
||||||
const field = scope.scope_type === 'game' ? server.game : server.map;
|
const field = scope.scope_type === 'game' ? server.game : server.map;
|
||||||
return field === scope.scope_value;
|
return field === scope.scope_value;
|
||||||
}
|
}
|
||||||
@@ -265,10 +266,12 @@ export function isActiveBetaServer(betaId: number, server: string): boolean {
|
|||||||
.get(betaId, server);
|
.get(betaId, server);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listBetaScripts(betaId: number): BetaScriptRow[] {
|
export function listBetaScripts(betaId: number, server?: string): BetaScriptRow[] {
|
||||||
return db()
|
return db()
|
||||||
.prepare('SELECT * FROM beta_scripts WHERE beta_id = ? ORDER BY script_id')
|
.prepare(`SELECT bs.* FROM beta_scripts bs WHERE bs.beta_id = ?
|
||||||
.all(betaId) as BetaScriptRow[];
|
${server === undefined ? '' : 'AND EXISTS (SELECT 1 FROM beta_server_scripts bss WHERE bss.beta_id = bs.beta_id AND bss.script_id = bs.script_id AND bss.server = ?)'}
|
||||||
|
ORDER BY bs.script_id`)
|
||||||
|
.all(betaId, ...(server === undefined ? [] : [server])) as BetaScriptRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBetaServerScript(betaId: number, server: string, scriptId: number): BetaServerScriptRow | undefined {
|
export function getBetaServerScript(betaId: number, server: string, scriptId: number): BetaServerScriptRow | undefined {
|
||||||
@@ -299,7 +302,8 @@ export interface OpenBetaScript {
|
|||||||
|
|
||||||
export interface OpenBetaInput {
|
export interface OpenBetaInput {
|
||||||
scripts: OpenBetaScript[];
|
scripts: OpenBetaScript[];
|
||||||
servers: string[];
|
servers?: string[];
|
||||||
|
instancesPerGame?: Record<string, number>;
|
||||||
testPlan?: BetaPlan;
|
testPlan?: BetaPlan;
|
||||||
waitForHealth: boolean;
|
waitForHealth: boolean;
|
||||||
waitForMatch: boolean;
|
waitForMatch: boolean;
|
||||||
@@ -313,26 +317,18 @@ export interface OpenBetaResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||||
const plan = input.testPlan ? parseBetaPlan(input.testPlan) : null;
|
const automatic = input.instancesPerGame !== undefined;
|
||||||
if (new Set(input.servers).size !== input.servers.length) throw new BetaError("Select each server only once", "invalid-state");
|
if (automatic && input.servers !== undefined) throw new BetaError('Choose automatic instance counts or explicit servers, not both', 'invalid-state');
|
||||||
if (plan?.scope === "quorum" && plan.quorum > input.servers.length) throw new BetaError("Quorum exceeds selected servers", "invalid-state");
|
const plan = automatic ? parseBetaPlan({ ...(input.testPlan ?? defaultPlan()), scope: 'all' })
|
||||||
|
: input.testPlan ? parseBetaPlan(input.testPlan) : null;
|
||||||
if (!input.scripts || input.scripts.length === 0) {
|
if (!input.scripts || input.scripts.length === 0) {
|
||||||
throw new BetaError('at least one script is required', 'no-scripts');
|
throw new BetaError('at least one script is required', 'no-scripts');
|
||||||
}
|
}
|
||||||
if (!input.servers || input.servers.length === 0) {
|
|
||||||
throw new BetaError('at least one server is required', 'no-servers');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve every (script, version) pair and validate belonging.
|
// Resolve every (script, version) pair and validate belonging.
|
||||||
interface Resolved { script: ScriptRow; version: ScriptVersionRow }
|
interface Resolved { script: ScriptRow; version: ScriptVersionRow }
|
||||||
const resolved: Resolved[] = [];
|
const resolved: Resolved[] = [];
|
||||||
const seenScriptIds = new Set<number>();
|
const seenScriptIds = new Set<number>();
|
||||||
// For non-collapsing kinds (script/plugin), two artifacts targeting the
|
|
||||||
// same rel_path within one beta is an internal race — reject up front.
|
|
||||||
// Collapsing kinds (config/baseline) intentionally share a target file
|
|
||||||
// (dedicated_sp.cfg) and compose, so they skip this check.
|
|
||||||
const seenTargets = new Map<string, number>();
|
|
||||||
let baselineInBeta: Resolved | null = null;
|
|
||||||
for (const s of input.scripts) {
|
for (const s of input.scripts) {
|
||||||
if (seenScriptIds.has(s.scriptId)) {
|
if (seenScriptIds.has(s.scriptId)) {
|
||||||
throw new BetaError(`script ${s.scriptId} listed twice in one beta`, 'duplicate-script');
|
throw new BetaError(`script ${s.scriptId} listed twice in one beta`, 'duplicate-script');
|
||||||
@@ -348,33 +344,23 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
|||||||
'version-script-mismatch'
|
'version-script-mismatch'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isCollapsingKind(script.kind)) {
|
|
||||||
const targetKey = `${script.kind}:${script.target_rel_path}`;
|
|
||||||
if (seenTargets.has(targetKey)) {
|
|
||||||
throw new BetaError(
|
|
||||||
`${script.kind}s ${seenTargets.get(targetKey)} and ${script.id} share target ${script.target_rel_path}`,
|
|
||||||
'duplicate-target'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
seenTargets.set(targetKey, script.id);
|
|
||||||
}
|
|
||||||
if (script.kind === 'baseline') {
|
|
||||||
if (baselineInBeta) {
|
|
||||||
throw new BetaError(
|
|
||||||
`two baselines in one beta (${baselineInBeta.script.id}, ${script.id}) — betas support at most one baseline`,
|
|
||||||
'duplicate-target'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
baselineInBeta = { script, version };
|
|
||||||
}
|
|
||||||
resolved.push({ script, version });
|
resolved.push({ script, version });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let selectedServers = input.servers;
|
||||||
|
if (automatic) {
|
||||||
|
try { selectedServers = selectBetaServers(resolved.map(row => row.script), input.instancesPerGame); }
|
||||||
|
catch (error) { throw new BetaError((error as Error).message, 'invalid-state'); }
|
||||||
|
}
|
||||||
|
if (!selectedServers?.length) throw new BetaError('at least one server is required', 'no-servers');
|
||||||
|
if (new Set(selectedServers).size !== selectedServers.length) throw new BetaError('Select each server only once', 'invalid-state');
|
||||||
|
if (plan?.scope === 'quorum' && plan.quorum > selectedServers.length) throw new BetaError('Quorum exceeds selected servers', 'invalid-state');
|
||||||
|
|
||||||
// Look up server rows once.
|
// Look up server rows once.
|
||||||
const serverRows: Array<{ name: string; game: string | null; map: string | null }> = [];
|
const serverRows: Array<{ name: string; game: string | null; map: string | null }> = [];
|
||||||
for (const name of input.servers) {
|
for (const name of selectedServers) {
|
||||||
const row = getServer(name);
|
const row = getServer(name);
|
||||||
if (!row) throw new BetaError(`unknown server: ${name}`, 'unknown-server');
|
if (!row || row.destroyed_at) throw new BetaError(`unknown server: ${name}`, 'unknown-server');
|
||||||
// Once teardown is queued or in flight, opening a beta cannot safely
|
// Once teardown is queued or in flight, opening a beta cannot safely
|
||||||
// claim the instance. A failed remote request may still be running.
|
// claim the instance. A failed remote request may still be running.
|
||||||
const removal = db().prepare(`SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL
|
const removal = db().prepare(`SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL
|
||||||
@@ -391,29 +377,30 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
|||||||
if (missing) throw new BetaError('Coverage requires game/map metadata on every selected server', 'invalid-state');
|
if (missing) throw new BetaError('Coverage requires game/map metadata on every selected server', 'invalid-state');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-script scope validation. Aggregate all violations into one error.
|
// Each server receives only matching bundle members. Unscoped files apply
|
||||||
const outOfScope: Array<{ scriptId: number; scope: string; servers: string[] }> = [];
|
// to every selected server; every file must be exercised by at least one.
|
||||||
|
const perServer = new Map(serverRows.map(server => [server.name, resolved.filter(({ script }) => scopeAppliesToServer(script, server))]));
|
||||||
|
for (const [server, members] of perServer) {
|
||||||
|
if (!members.length) throw new BetaError(`${server}: no bundled files apply to this server`, 'out-of-scope');
|
||||||
|
const seenTargets = new Map<string, number>();
|
||||||
|
for (const { script } of members) {
|
||||||
|
if (script.kind === 'config') continue;
|
||||||
|
const target = script.kind === 'baseline' ? 'baseline' : `${script.kind}:${script.target_rel_path}`;
|
||||||
|
const prior = seenTargets.get(target);
|
||||||
|
if (prior !== undefined) throw new BetaError(`${server}: files ${prior} and ${script.id} share target ${script.target_rel_path}`, 'duplicate-target');
|
||||||
|
seenTargets.set(target, script.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const { script } of resolved) {
|
for (const { script } of resolved) {
|
||||||
if (!script.scope_type || !script.scope_value) continue;
|
if (!serverRows.some(server => scopeAppliesToServer(script, server))) {
|
||||||
const bad = serverRows.filter(
|
throw new BetaError(`${script.name}: select at least one server matching ${script.scope_type}=${script.scope_value}`, 'out-of-scope');
|
||||||
(r) => (script.scope_type === 'game' ? r.game : r.map) !== script.scope_value
|
|
||||||
);
|
|
||||||
if (bad.length > 0) {
|
|
||||||
outOfScope.push({
|
|
||||||
scriptId: script.id,
|
|
||||||
scope: `${script.scope_type}=${script.scope_value}`,
|
|
||||||
servers: bad.map((r) => r.name)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (outOfScope.length > 0) {
|
|
||||||
throw new BetaError('one or more servers are out of scope', 'out-of-scope', outOfScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A beta outranks campaigns during activation. Do not let it overtake older
|
// A beta outranks campaigns during activation. Do not let it overtake older
|
||||||
// staged writes which would otherwise overwrite the tested version on closure.
|
// staged writes which would otherwise overwrite the tested version on closure.
|
||||||
// Legacy campaigns have no staged payload and retain their restart-only priority.
|
// Legacy campaigns have no staged payload and retain their restart-only priority.
|
||||||
const queuedCampaigns = input.servers.flatMap(unresolvedCampaignWrites);
|
const queuedCampaigns = selectedServers.flatMap(unresolvedCampaignWrites);
|
||||||
if (queuedCampaigns.length) {
|
if (queuedCampaigns.length) {
|
||||||
const interrupted = queuedCampaigns.some(row => row.state === 'applying');
|
const interrupted = queuedCampaigns.some(row => row.state === 'applying');
|
||||||
throw new BetaError(interrupted
|
throw new BetaError(interrupted
|
||||||
@@ -425,9 +412,9 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
|||||||
// beta already claimed this target"; the previous stale-.OLD check is no
|
// beta already claimed this target"; the previous stale-.OLD check is no
|
||||||
// longer relevant now that snapshots live in the DB.
|
// longer relevant now that snapshots live in the DB.
|
||||||
const conflicts: Array<{ server: string; scriptId: number; heldByBetaId: number }> = [];
|
const conflicts: Array<{ server: string; scriptId: number; heldByBetaId: number }> = [];
|
||||||
for (const name of input.servers) {
|
for (const name of selectedServers) {
|
||||||
const held = heldByBeta(name);
|
const held = heldByBeta(name);
|
||||||
for (const { script } of resolved) {
|
for (const { script } of perServer.get(name)!) {
|
||||||
const owner = held.get(script.target_rel_path);
|
const owner = held.get(script.target_rel_path);
|
||||||
if (owner) conflicts.push({ server: name, scriptId: script.id, heldByBetaId: owner });
|
if (owner) conflicts.push({ server: name, scriptId: script.id, heldByBetaId: owner });
|
||||||
}
|
}
|
||||||
@@ -466,13 +453,13 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
|||||||
for (const r of resolved) {
|
for (const r of resolved) {
|
||||||
insertBetaScript.run(betaId, r.script.id, r.version.id, r.script.kind);
|
insertBetaScript.run(betaId, r.script.id, r.version.id, r.script.kind);
|
||||||
}
|
}
|
||||||
for (const name of input.servers) {
|
for (const name of selectedServers) {
|
||||||
insertBetaServer.run(betaId, name, nowIso());
|
insertBetaServer.run(betaId, name, nowIso());
|
||||||
if (plan) {
|
if (plan) {
|
||||||
const selected = serverRows.find(s => s.name === name)!;
|
const selected = serverRows.find(s => s.name === name)!;
|
||||||
db().prepare('INSERT INTO beta_test_coverage (beta_id, server, game, map) VALUES (?, ?, ?, ?)').run(betaId, name, selected.game, selected.map);
|
db().prepare('INSERT INTO beta_test_coverage (beta_id, server, game, map) VALUES (?, ?, ?, ?)').run(betaId, name, selected.game, selected.map);
|
||||||
}
|
}
|
||||||
for (const r of resolved) {
|
for (const r of perServer.get(name)!) {
|
||||||
insertBetaServerScript.run(
|
insertBetaServerScript.run(
|
||||||
betaId,
|
betaId,
|
||||||
name,
|
name,
|
||||||
@@ -485,18 +472,18 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
if (plan?.waveSize) db().prepare("UPDATE beta_servers SET stage = 'queued' WHERE beta_id = ?").run(betaId);
|
if (plan?.waveSize) db().prepare("UPDATE beta_servers SET stage = 'queued' WHERE beta_id = ?").run(betaId);
|
||||||
await deployBetaServers(betaId, plan?.waveSize ? input.servers.slice(0, plan.waveSize) : input.servers);
|
await deployBetaServers(betaId, plan?.waveSize ? selectedServers.slice(0, plan.waveSize) : selectedServers);
|
||||||
|
|
||||||
emit({
|
emit({
|
||||||
type: 'beta-opened',
|
type: 'beta-opened',
|
||||||
betaId,
|
betaId,
|
||||||
scriptIds: resolved.map((r) => r.script.id),
|
scriptIds: resolved.map((r) => r.script.id),
|
||||||
servers: input.servers
|
servers: selectedServers
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
betaId,
|
betaId,
|
||||||
servers: input.servers,
|
servers: selectedServers,
|
||||||
scripts: resolved.map((r) => ({ scriptId: r.script.id, scriptVersionId: r.version.id }))
|
scripts: resolved.map((r) => ({ scriptId: r.script.id, scriptVersionId: r.version.id }))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -602,7 +589,7 @@ export async function reviseBeta(betaId: number, expectedRevision: number, uploa
|
|||||||
}
|
}
|
||||||
|
|
||||||
function revisionFileChanges(betaId: number, name: string): Parameters<typeof queueFileChange>[0][] {
|
function revisionFileChanges(betaId: number, name: string): Parameters<typeof queueFileChange>[0][] {
|
||||||
const resolved = listBetaScripts(betaId).map(row => ({ script: getScript(row.script_id)!, version: getVersion(row.script_version_id)! }));
|
const resolved = listBetaScripts(betaId, name).map(row => ({ script: getScript(row.script_id)!, version: getVersion(row.script_version_id)! }));
|
||||||
const server = getServer(name)!;
|
const server = getServer(name)!;
|
||||||
const changes: Parameters<typeof queueFileChange>[0][] = resolved.filter(row => !isCollapsingKind(row.script.kind))
|
const changes: Parameters<typeof queueFileChange>[0][] = resolved.filter(row => !isCollapsingKind(row.script.kind))
|
||||||
.map(({ script, version }) => ({ sourceKind: 'beta', sourceId: String(betaId), server: name, kind: script.kind,
|
.map(({ script, version }) => ({ sourceKind: 'beta', sourceId: String(betaId), server: name, kind: script.kind,
|
||||||
@@ -612,7 +599,7 @@ function revisionFileChanges(betaId: number, name: string): Parameters<typeof qu
|
|||||||
inBetaBaseline: resolved.find(row => row.script.kind === 'baseline')?.version,
|
inBetaBaseline: resolved.find(row => row.script.kind === 'baseline')?.version,
|
||||||
inBetaOverlays: resolved.filter(row => row.script.kind === 'config')
|
inBetaOverlays: resolved.filter(row => row.script.kind === 'config')
|
||||||
});
|
});
|
||||||
if (!composed) throw new BetaError(`No baseline available for ${name}.`, 'invalid-state');
|
if (!composed) throw new BetaError(`no baseline available for ${name} — upload one before running config overlays`, 'invalid-state');
|
||||||
changes.push({ sourceKind: 'beta', sourceId: String(betaId), server: name, kind: 'config', relPath: configFileOf(name),
|
changes.push({ sourceKind: 'beta', sourceId: String(betaId), server: name, kind: 'config', relPath: configFileOf(name),
|
||||||
content: Buffer.from(composed.text), inventorySource: `beta:${betaId}` });
|
content: Buffer.from(composed.text), inventorySource: `beta:${betaId}` });
|
||||||
}
|
}
|
||||||
@@ -628,47 +615,24 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
|
|||||||
if (!isActiveBetaServer(betaId, name)) throw new BetaError(`${name}: original beta server was deleted; start a new beta`, 'unknown-server');
|
if (!isActiveBetaServer(betaId, name)) throw new BetaError(`${name}: original beta server was deleted; start a new beta`, 'unknown-server');
|
||||||
}
|
}
|
||||||
const resolved = listBetaScripts(betaId).map(r => ({ script: getScript(r.script_id)!, version: getVersion(r.script_version_id)! }));
|
const resolved = listBetaScripts(betaId).map(r => ({ script: getScript(r.script_id)!, version: getVersion(r.script_version_id)! }));
|
||||||
const serverRows = names.map(name => getServer(name)!);
|
|
||||||
// Split resolved artifacts into non-collapsing (each writes its own file)
|
|
||||||
// and collapsing (config/baseline overlay onto one shared dedicated_sp.cfg).
|
|
||||||
const nonCollapsing = resolved.filter((r) => !isCollapsingKind(r.script.kind));
|
const nonCollapsing = resolved.filter((r) => !isCollapsingKind(r.script.kind));
|
||||||
const collapsingOverlays = resolved.filter((r) => r.script.kind === 'config');
|
|
||||||
const collapsingBaseline = resolved.find((r) => r.script.kind === 'baseline');
|
|
||||||
|
|
||||||
// Archive: one copy per non-collapsing artifact under its target basename,
|
// Script IDs distinguish same-named files for different games in archives.
|
||||||
// plus one composed preview per beta server for the config layer.
|
|
||||||
const archive = betaArchiveOf(betaId);
|
const archive = betaArchiveOf(betaId);
|
||||||
await mkdir(archive, { recursive: true });
|
await mkdir(archive, { recursive: true });
|
||||||
for (const r of nonCollapsing) {
|
for (const r of nonCollapsing) {
|
||||||
const base = posix.basename(r.script.target_rel_path);
|
const base = posix.basename(r.script.target_rel_path);
|
||||||
await writeFile(join(archive, base), getVersionContent(r.version.id));
|
await writeFile(join(archive, `${r.script.id}.${base}`), getVersionContent(r.version.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the requested bytes without touching any live destination. Provisioning
|
// Persist the requested bytes without touching any live destination. Provisioning
|
||||||
// captures the original file and inventory immediately before guarded activation.
|
// captures the original file and inventory immediately before guarded activation.
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
if (!isActiveBetaServer(betaId, name)) continue;
|
if (!isActiveBetaServer(betaId, name)) continue;
|
||||||
const serverRow = serverRows.find((r) => r.name === name)!;
|
|
||||||
try {
|
try {
|
||||||
const changes: Parameters<typeof queueFileChange>[0][] = nonCollapsing.map(({ script, version }) => ({
|
const changes = revisionFileChanges(betaId, name);
|
||||||
sourceKind: 'beta' as const, sourceId: String(betaId), server: name,
|
const configChange = changes.find(change => change.kind === 'config');
|
||||||
kind: script.kind, relPath: script.target_rel_path, content: getVersionContent(version.id),
|
if (configChange?.content) await writeFile(join(archive, `${name}.${configFileOf(name)}`), configChange.content);
|
||||||
scriptVersionId: version.id, inventorySource: `beta:${betaId}`
|
|
||||||
}));
|
|
||||||
if (collapsingBaseline || collapsingOverlays.length > 0) {
|
|
||||||
const composedResult = composeCfgForServer(name, serverRow.game, serverRow.map, {
|
|
||||||
inBetaBaseline: collapsingBaseline?.version,
|
|
||||||
inBetaOverlays: collapsingOverlays
|
|
||||||
});
|
|
||||||
if (!composedResult) {
|
|
||||||
throw new Error(`no baseline available for ${name} (game=${serverRow.game ?? '?'}) — upload one before running config overlays`);
|
|
||||||
}
|
|
||||||
const composedBuf = Buffer.from(composedResult.text, 'utf8');
|
|
||||||
changes.push({ sourceKind: 'beta', sourceId: String(betaId), server: name,
|
|
||||||
kind: 'config', relPath: configFileOf(name), content: composedBuf,
|
|
||||||
scriptVersionId: null, inventorySource: `beta:${betaId}` });
|
|
||||||
await writeFile(join(archive, `${name}.${configFileOf(name)}`), composedBuf);
|
|
||||||
}
|
|
||||||
if (!isActiveBetaServer(betaId, name)) continue;
|
if (!isActiveBetaServer(betaId, name)) continue;
|
||||||
db().transaction(() => {
|
db().transaction(() => {
|
||||||
for (const change of changes) queueFileChange(change);
|
for (const change of changes) queueFileChange(change);
|
||||||
@@ -732,8 +696,6 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
|
|||||||
const dropped = new Set<string>();
|
const dropped = new Set<string>();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
const hasCollapsing = bundle.some((b) => isCollapsingKind(b.script.kind));
|
|
||||||
|
|
||||||
const getSnapshot = db().prepare(
|
const getSnapshot = db().prepare(
|
||||||
`SELECT ${blobContent('prior_content_hash')} AS prior_content, prior_sha256, prior_size FROM beta_file_snapshots
|
`SELECT ${blobContent('prior_content_hash')} AS prior_content, prior_sha256, prior_size FROM beta_file_snapshots
|
||||||
WHERE beta_id = ? AND server = ? AND rel_path = ?`
|
WHERE beta_id = ? AND server = ? AND rel_path = ?`
|
||||||
@@ -754,7 +716,8 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
|
|||||||
warnings.push(`${bs.server}: original server was deleted; retained history was skipped`);
|
warnings.push(`${bs.server}: original server was deleted; retained history was skipped`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const { script } of bundle) {
|
const members = bundle.filter(({ script }) => getBetaServerScript(betaId, bs.server, script.id));
|
||||||
|
for (const { script } of members) {
|
||||||
if (isCollapsingKind(script.kind) || !applied(bs, script.kind, script.target_rel_path)) continue;
|
if (isCollapsingKind(script.kind) || !applied(bs, script.kind, script.target_rel_path)) continue;
|
||||||
const bss = getBetaServerScript(betaId, bs.server, script.id);
|
const bss = getBetaServerScript(betaId, bs.server, script.id);
|
||||||
const snap = getSnapshot.get(betaId, bs.server, script.target_rel_path) as
|
const snap = getSnapshot.get(betaId, bs.server, script.target_rel_path) as
|
||||||
@@ -769,7 +732,7 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
|
|||||||
(snap ? restored : dropped).add(bs.server);
|
(snap ? restored : dropped).add(bs.server);
|
||||||
}
|
}
|
||||||
const targetRel = configFileOf(bs.server);
|
const targetRel = configFileOf(bs.server);
|
||||||
if (hasCollapsing && applied(bs, 'config', targetRel)) {
|
if (members.some(({ script }) => isCollapsingKind(script.kind)) && applied(bs, 'config', targetRel)) {
|
||||||
const snap = getSnapshot.get(betaId, bs.server, targetRel) as
|
const snap = getSnapshot.get(betaId, bs.server, targetRel) as
|
||||||
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
|
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -878,8 +841,12 @@ function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configC
|
|||||||
if (!bundle.length) throw new BetaError('This beta has no files.', 'no-scripts');
|
if (!bundle.length) throw new BetaError('This beta has no files.', 'no-scripts');
|
||||||
const participants = listBetaServers(betaId);
|
const participants = listBetaServers(betaId);
|
||||||
const tested = new Set(participants.filter(row => isActiveBetaServer(betaId, row.server)).map(row => row.server));
|
const tested = new Set(participants.filter(row => isActiveBetaServer(betaId, row.server)).map(row => row.server));
|
||||||
const targets = (script: ScriptRow) => [...new Set([...tested, ...(script.scope_type && script.scope_value
|
const targetsByScript = new Map(bundle.map(({ script }) => {
|
||||||
? serversMatchingScope(script.scope_type, script.scope_value) : [])])].sort();
|
const participants = [...tested].filter(server => getBetaServerScript(betaId, server, script.id));
|
||||||
|
const scopeServers = script.scope_type && script.scope_value ? serversMatchingScope(script.scope_type, script.scope_value) : [];
|
||||||
|
return [script.id, [...new Set([...participants, ...scopeServers])].sort()];
|
||||||
|
}));
|
||||||
|
const targets = (script: ScriptRow) => targetsByScript.get(script.id)!;
|
||||||
const files = bundle.map(({ script, version }) => ({ id: script.id, name: script.name, kind: script.kind,
|
const files = bundle.map(({ script, version }) => ({ id: script.id, name: script.name, kind: script.kind,
|
||||||
path: script.target_rel_path, versionId: version.id, version: version.version,
|
path: script.target_rel_path, versionId: version.id, version: version.version,
|
||||||
release_version: version.release_version, sha256: version.sha256,
|
release_version: version.release_version, sha256: version.sha256,
|
||||||
@@ -890,15 +857,23 @@ function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configC
|
|||||||
const servers = names.map(name => {
|
const servers = names.map(name => {
|
||||||
const server = getServer(name);
|
const server = getServer(name);
|
||||||
if (!server || server.destroyed_at) blockers.push(`${name}: server is no longer available.`);
|
if (!server || server.destroyed_at) blockers.push(`${name}: server is no longer available.`);
|
||||||
|
const destinations = new Map<string, number>();
|
||||||
|
for (const file of files.filter(file => file.servers.includes(name) && file.kind !== 'config')) {
|
||||||
|
const target = file.kind === 'baseline' ? 'baseline' : `${file.kind}:${file.path}`;
|
||||||
|
const prior = destinations.get(target);
|
||||||
|
if (prior !== undefined) blockers.push(`${name}: files ${prior} and ${file.id} share target ${file.path}.`);
|
||||||
|
destinations.set(target, file.id);
|
||||||
|
}
|
||||||
return { name, tested: tested.has(name), game: server?.game ?? null, map: server?.map ?? null };
|
return { name, tested: tested.has(name), game: server?.game ?? null, map: server?.map ?? null };
|
||||||
});
|
});
|
||||||
const collapsing = bundle.filter(({ script }) => isCollapsingKind(script.kind));
|
const collapsing = bundle.filter(({ script }) => isCollapsingKind(script.kind));
|
||||||
const configContent = new Map<string, Buffer>();
|
const configContent = new Map<string, Buffer>();
|
||||||
const configWrites = [...new Set(collapsing.flatMap(({ script }) => targets(script)))].sort().map(server => {
|
const configWrites = [...new Set(collapsing.flatMap(({ script }) => targets(script)))].sort().map(server => {
|
||||||
const row = getServer(server);
|
const row = getServer(server);
|
||||||
|
const matching = collapsing.filter(({ script }) => targets(script).includes(server));
|
||||||
const composed = row && composeCfgForServer(server, row.game, row.map, {
|
const composed = row && composeCfgForServer(server, row.game, row.map, {
|
||||||
inBetaBaseline: collapsing.find(({ script }) => script.kind === 'baseline')?.version,
|
inBetaBaseline: matching.find(({ script }) => script.kind === 'baseline')?.version,
|
||||||
inBetaOverlays: collapsing.filter(({ script }) => script.kind === 'config')
|
inBetaOverlays: matching.filter(({ script }) => script.kind === 'config')
|
||||||
});
|
});
|
||||||
const error = composed ? null : `${server}: no baseline is available to compose its configuration.`;
|
const error = composed ? null : `${server}: no baseline is available to compose its configuration.`;
|
||||||
if (error) blockers.push(error);
|
if (error) blockers.push(error);
|
||||||
@@ -966,6 +941,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
|||||||
priorSha: string | null;
|
priorSha: string | null;
|
||||||
priorContent: Buffer | null;
|
priorContent: Buffer | null;
|
||||||
hadFile: boolean;
|
hadFile: boolean;
|
||||||
|
tested: boolean;
|
||||||
}
|
}
|
||||||
const plans: Plan[] = [];
|
const plans: Plan[] = [];
|
||||||
|
|
||||||
@@ -979,8 +955,8 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
|||||||
|
|
||||||
for (const name of targets) {
|
for (const name of targets) {
|
||||||
let plan: Plan;
|
let plan: Plan;
|
||||||
if (betaServerSet.has(name)) {
|
|
||||||
const bss = getBetaServerScript(betaId, name, script.id);
|
const bss = getBetaServerScript(betaId, name, script.id);
|
||||||
|
if (bss && betaServerSet.has(name)) {
|
||||||
if (bss?.had_original) {
|
if (bss?.had_original) {
|
||||||
const snap = getSnapshotForPromote.get(betaId, name, script.target_rel_path) as
|
const snap = getSnapshotForPromote.get(betaId, name, script.target_rel_path) as
|
||||||
| { prior_content: Buffer; prior_sha256: string }
|
| { prior_content: Buffer; prior_sha256: string }
|
||||||
@@ -996,15 +972,15 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
|||||||
}
|
}
|
||||||
plan = {
|
plan = {
|
||||||
script, version, server: name,
|
script, version, server: name,
|
||||||
priorVersionId: bss.prior_version_id, priorSha, priorContent, hadFile: true
|
priorVersionId: bss.prior_version_id, priorSha, priorContent, hadFile: true, tested: true
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false };
|
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false, tested: true };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Campaign activation captures the actual before image just
|
// Campaign activation captures the actual before image just
|
||||||
// before replacing this file. Do not wait on remote reads here.
|
// before replacing this file. Do not wait on remote reads here.
|
||||||
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false };
|
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false, tested: false };
|
||||||
}
|
}
|
||||||
plans.push(plan);
|
plans.push(plan);
|
||||||
}
|
}
|
||||||
@@ -1023,6 +999,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
|||||||
composedSha: string;
|
composedSha: string;
|
||||||
hadFile: boolean;
|
hadFile: boolean;
|
||||||
requiresActivation: boolean;
|
requiresActivation: boolean;
|
||||||
|
tested: boolean;
|
||||||
}
|
}
|
||||||
const cfgPlans: CfgPlan[] = [];
|
const cfgPlans: CfgPlan[] = [];
|
||||||
if (collapsing.length > 0) {
|
if (collapsing.length > 0) {
|
||||||
@@ -1046,8 +1023,9 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
|||||||
let priorContent: Buffer | null = null;
|
let priorContent: Buffer | null = null;
|
||||||
let priorSha: string | null = null;
|
let priorSha: string | null = null;
|
||||||
let hadFile = false;
|
let hadFile = false;
|
||||||
|
const tested = betaServerSet.has(name) && collapsing.some(({ script }) => getBetaServerScript(betaId, name, script.id));
|
||||||
// Beta server: preserve its original bytes captured at activation.
|
// Beta server: preserve its original bytes captured at activation.
|
||||||
if (betaServerSet.has(name)) {
|
if (tested) {
|
||||||
const snap = getSnapshotForPromote.get(betaId, name, targetRel) as
|
const snap = getSnapshotForPromote.get(betaId, name, targetRel) as
|
||||||
| { prior_content: Buffer; prior_sha256: string }
|
| { prior_content: Buffer; prior_sha256: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -1057,9 +1035,9 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
|||||||
hadFile = true;
|
hadFile = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const requiresActivation = !betaServerSet.has(name) || !await exists(targetPath)
|
const requiresActivation = !tested || !await exists(targetPath)
|
||||||
|| sha256Buffer(await readFile(targetPath)) !== composedSha;
|
|| sha256Buffer(await readFile(targetPath)) !== composedSha;
|
||||||
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile, requiresActivation });
|
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile, requiresActivation, tested });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1070,7 +1048,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
|||||||
// beta was active. Reassert the tested bytes after that older campaign, even
|
// beta was active. Reassert the tested bytes after that older campaign, even
|
||||||
// when they already match the live file, so it cannot overwrite this release.
|
// when they already match the live file, so it cannot overwrite this release.
|
||||||
const queuedByServer = new Map(reviewed.preview.servers.map(server => [server.name, unresolvedCampaignWrites(server.name)]));
|
const queuedByServer = new Map(reviewed.preview.servers.map(server => [server.name, unresolvedCampaignWrites(server.name)]));
|
||||||
const plansToActivate = new Set(plans.filter(p => !betaServerSet.has(p.server)
|
const plansToActivate = new Set(plans.filter(p => !p.tested
|
||||||
|| queuedByServer.get(p.server)?.some(row => row.kind === p.script.kind && row.rel_path === p.script.target_rel_path)));
|
|| queuedByServer.get(p.server)?.some(row => row.kind === p.script.kind && row.rel_path === p.script.target_rel_path)));
|
||||||
for (const cp of cfgPlans) {
|
for (const cp of cfgPlans) {
|
||||||
if (queuedByServer.get(cp.server)?.some(row => row.kind === 'config' && row.rel_path === configFileOf(cp.server))) cp.requiresActivation = true;
|
if (queuedByServer.get(cp.server)?.some(row => row.kind === 'config' && row.rel_path === configFileOf(cp.server))) cp.requiresActivation = true;
|
||||||
@@ -1124,13 +1102,13 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
|||||||
p.version.sha256,
|
p.version.sha256,
|
||||||
putFileBlob(p.priorContent),
|
putFileBlob(p.priorContent),
|
||||||
putFileBlob(getVersionContent(p.version.id)),
|
putFileBlob(getVersionContent(p.version.id)),
|
||||||
betaServerSet.has(p.server) ? 0 : 1
|
p.tested ? 0 : 1
|
||||||
);
|
);
|
||||||
if (plansToActivate.has(p)) {
|
if (plansToActivate.has(p)) {
|
||||||
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: p.server,
|
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: p.server,
|
||||||
kind: p.script.kind, relPath: p.script.target_rel_path, content: getVersionContent(p.version.id),
|
kind: p.script.kind, relPath: p.script.target_rel_path, content: getVersionContent(p.version.id),
|
||||||
scriptVersionId: p.version.id, inventorySource: `promote:${depId}`,
|
scriptVersionId: p.version.id, inventorySource: `promote:${depId}`,
|
||||||
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: betaServerSet.has(p.server) });
|
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: p.tested });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const cp of cfgPlans) {
|
for (const cp of cfgPlans) {
|
||||||
@@ -1144,13 +1122,13 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
|||||||
cp.composedSha,
|
cp.composedSha,
|
||||||
putFileBlob(cp.priorContent),
|
putFileBlob(cp.priorContent),
|
||||||
putFileBlob(cp.composedContent),
|
putFileBlob(cp.composedContent),
|
||||||
betaServerSet.has(cp.server) ? 0 : 1
|
cp.tested ? 0 : 1
|
||||||
);
|
);
|
||||||
if (cp.requiresActivation) {
|
if (cp.requiresActivation) {
|
||||||
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: cp.server,
|
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: cp.server,
|
||||||
kind: 'config', relPath: configFileOf(cp.server), content: cp.composedContent,
|
kind: 'config', relPath: configFileOf(cp.server), content: cp.composedContent,
|
||||||
scriptVersionId: null, inventorySource: `promote:${depId}`,
|
scriptVersionId: null, inventorySource: `promote:${depId}`,
|
||||||
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: betaServerSet.has(cp.server) });
|
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: cp.tested });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// deployment_servers — one row per unique server. Beta servers are
|
// deployment_servers — one row per unique server. Beta servers are
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface DeploymentRow {
|
|||||||
script_version_id: number | null;
|
script_version_id: number | null;
|
||||||
prior_version_id: number | null;
|
prior_version_id: number | null;
|
||||||
rolled_back_by: string | null;
|
rolled_back_by: string | null;
|
||||||
|
beta_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DepScriptRow {
|
interface DepScriptRow {
|
||||||
@@ -70,7 +71,7 @@ export interface RollbackDeploymentResult {
|
|||||||
|
|
||||||
export async function rollbackPromoteDeployment(promoteId: string): Promise<RollbackDeploymentResult> {
|
export async function rollbackPromoteDeployment(promoteId: string): Promise<RollbackDeploymentResult> {
|
||||||
const dep = db()
|
const dep = db()
|
||||||
.prepare(`SELECT id, action, script_id, script_version_id, prior_version_id, rolled_back_by
|
.prepare(`SELECT id, action, script_id, script_version_id, prior_version_id, rolled_back_by, beta_id
|
||||||
FROM deployments WHERE id = ?`)
|
FROM deployments WHERE id = ?`)
|
||||||
.get(promoteId) as DeploymentRow | undefined;
|
.get(promoteId) as DeploymentRow | undefined;
|
||||||
if (!dep) throw new RollbackError(`no such deployment ${promoteId}`, 'no-such-deployment');
|
if (!dep) throw new RollbackError(`no such deployment ${promoteId}`, 'no-such-deployment');
|
||||||
@@ -109,9 +110,9 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
|||||||
)
|
)
|
||||||
.all(promoteId) as FileRow[];
|
.all(promoteId) as FileRow[];
|
||||||
|
|
||||||
// Map rel_path → scriptId (and prior_version_id) so we can restore the
|
// Different games may promote distinct scripts with the same target path.
|
||||||
// inventory row's script_version_id correctly during file restore.
|
// Retain all candidates and resolve ownership from the per-server history.
|
||||||
const scriptByTarget = new Map<string, DepScriptRow>();
|
const scriptsByTarget = new Map<string, DepScriptRow[]>();
|
||||||
const kindByScriptId = new Map<number, ArtifactKind>();
|
const kindByScriptId = new Map<number, ArtifactKind>();
|
||||||
if (scripts.length > 0) {
|
if (scripts.length > 0) {
|
||||||
const placeholders = scripts.map(() => '?').join(',');
|
const placeholders = scripts.map(() => '?').join(',');
|
||||||
@@ -125,11 +126,25 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
|||||||
}
|
}
|
||||||
for (const s of scripts) {
|
for (const s of scripts) {
|
||||||
const t = targetById.get(s.script_id);
|
const t = targetById.get(s.script_id);
|
||||||
if (t) scriptByTarget.set(t, s);
|
if (t) scriptsByTarget.set(t, [...(scriptsByTarget.get(t) ?? []), s]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const kindForRelPath = (relPath: string): ArtifactKind => {
|
const staged = stagedFileChanges('campaign', promoteId);
|
||||||
const owner = scriptByTarget.get(relPath);
|
const scriptForFile = (file: FileRow): DepScriptRow | undefined => {
|
||||||
|
const candidates = scriptsByTarget.get(file.rel_path) ?? [];
|
||||||
|
const versionId = staged.find(row => row.deployment_file_id === file.id)?.script_version_id;
|
||||||
|
if (versionId) return candidates.find(script => script.script_version_id === versionId);
|
||||||
|
if (dep.beta_id) {
|
||||||
|
const participant = candidates.find(script => db().prepare(`SELECT 1 FROM beta_server_scripts
|
||||||
|
WHERE beta_id = ? AND server = ? AND script_id = ?`).get(dep.beta_id, file.server, script.script_id));
|
||||||
|
if (participant) return participant;
|
||||||
|
}
|
||||||
|
return candidates.find(script => getVersion(script.script_version_id)?.sha256 === file.sha256_after) ?? candidates[0];
|
||||||
|
};
|
||||||
|
const kindForFile = (file: FileRow): ArtifactKind => {
|
||||||
|
const stagedKind = staged.find(row => row.deployment_file_id === file.id)?.kind;
|
||||||
|
if (stagedKind) return stagedKind;
|
||||||
|
const owner = scriptForFile(file);
|
||||||
if (owner) return owner.kind ?? kindByScriptId.get(owner.script_id) ?? 'script';
|
if (owner) return owner.kind ?? kindByScriptId.get(owner.script_id) ?? 'script';
|
||||||
// Config/baseline aggregate rows have no per-artifact target_rel_path
|
// Config/baseline aggregate rows have no per-artifact target_rel_path
|
||||||
// mapping — the deployment_files row is keyed on the shared cfg name.
|
// mapping — the deployment_files row is keyed on the shared cfg name.
|
||||||
@@ -144,10 +159,6 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
|||||||
|
|
||||||
// Group files by server so each server gets exactly one deployment_servers row.
|
// Group files by server so each server gets exactly one deployment_servers row.
|
||||||
const perServer = new Map<string, FileRow[]>();
|
const perServer = new Map<string, FileRow[]>();
|
||||||
const staged = stagedFileChanges('campaign', promoteId);
|
|
||||||
// Composed baseline rows are physically staged as config files. Prefer the
|
|
||||||
// explicit link instead of inferring their destination from the library.
|
|
||||||
const kindForFile = (file: FileRow) => staged.find(row => row.deployment_file_id === file.id)?.kind ?? kindForRelPath(file.rel_path);
|
|
||||||
for (const f of fileRows) {
|
for (const f of fileRows) {
|
||||||
// A promotion can still be waiting for empty servers. Withdrawing it
|
// A promotion can still be waiting for empty servers. Withdrawing it
|
||||||
// must not restore or remove files that it never installed. Rows from
|
// must not restore or remove files that it never installed. Rows from
|
||||||
@@ -230,8 +241,12 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
|||||||
putFileBlob(f.content_after),
|
putFileBlob(f.content_after),
|
||||||
putFileBlob(f.content_before)
|
putFileBlob(f.content_before)
|
||||||
);
|
);
|
||||||
const owningScript = scriptByTarget.get(f.rel_path);
|
const owningScript = scriptForFile(f);
|
||||||
const priorVersion = owningScript?.prior_version_id ? getVersion(owningScript.prior_version_id) : null;
|
const betaPrior = dep.beta_id && owningScript ? db().prepare(`SELECT prior_version_id FROM beta_server_scripts
|
||||||
|
WHERE beta_id = ? AND server = ? AND script_id = ?`).get(dep.beta_id, server, owningScript.script_id) as
|
||||||
|
{ prior_version_id: number | null } | undefined : undefined;
|
||||||
|
const priorVersionId = betaPrior ? betaPrior.prior_version_id : owningScript?.prior_version_id;
|
||||||
|
const priorVersion = priorVersionId ? getVersion(priorVersionId) : null;
|
||||||
queueFileChange({ sourceKind: 'campaign', sourceId: newDepId, server,
|
queueFileChange({ sourceKind: 'campaign', sourceId: newDepId, server,
|
||||||
kind: kindForFile(f), relPath: f.rel_path,
|
kind: kindForFile(f), relPath: f.rel_path,
|
||||||
content: f.verb === 'added' ? null : f.content_before,
|
content: f.verb === 'added' ? null : f.content_before,
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ export function heldByBeta(server: string): Map<string, number> {
|
|||||||
`SELECT s.target_rel_path AS target, b.id AS beta_id
|
`SELECT s.target_rel_path AS target, b.id AS beta_id
|
||||||
FROM beta_servers bsv
|
FROM beta_servers bsv
|
||||||
JOIN betas b ON b.id = bsv.beta_id
|
JOIN betas b ON b.id = bsv.beta_id
|
||||||
JOIN beta_scripts bs ON bs.beta_id = b.id
|
JOIN beta_server_scripts bs ON bs.beta_id = b.id AND bs.server = bsv.server
|
||||||
JOIN scripts s ON s.id = bs.script_id
|
JOIN scripts s ON s.id = bs.script_id
|
||||||
WHERE bsv.server = ? AND bsv.server_deleted_at IS NULL AND b.state IN ('running','ready-promote')`
|
WHERE bsv.server = ? AND bsv.server_deleted_at IS NULL AND b.state IN ('running','ready-promote')`
|
||||||
)
|
)
|
||||||
@@ -247,7 +247,7 @@ export function ownerOf(server: string, target: string): number | null {
|
|||||||
`SELECT b.id AS beta_id
|
`SELECT b.id AS beta_id
|
||||||
FROM beta_servers bsv
|
FROM beta_servers bsv
|
||||||
JOIN betas b ON b.id = bsv.beta_id
|
JOIN betas b ON b.id = bsv.beta_id
|
||||||
JOIN beta_scripts bs ON bs.beta_id = b.id
|
JOIN beta_server_scripts bs ON bs.beta_id = b.id AND bs.server = bsv.server
|
||||||
JOIN scripts s ON s.id = bs.script_id
|
JOIN scripts s ON s.id = bs.script_id
|
||||||
WHERE bsv.server = ? AND bsv.server_deleted_at IS NULL AND s.target_rel_path = ? AND b.state IN ('running','ready-promote')
|
WHERE bsv.server = ? AND bsv.server_deleted_at IS NULL AND s.target_rel_path = ? AND b.state IN ('running','ready-promote')
|
||||||
LIMIT 1`
|
LIMIT 1`
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export function fleetSnapshot(serverName?: string) {
|
|||||||
.all(...bindings) as NativeInstance[]).map(instance => [instance.server, instance]));
|
.all(...bindings) as NativeInstance[]).map(instance => [instance.server, instance]));
|
||||||
const holds = new Map<string, { target: string; betaId: number }[]>();
|
const holds = new Map<string, { target: string; betaId: number }[]>();
|
||||||
for (const row of db().prepare(`SELECT bsv.server, s.target_rel_path AS target, b.id AS betaId
|
for (const row of db().prepare(`SELECT bsv.server, s.target_rel_path AS target, b.id AS betaId
|
||||||
FROM beta_servers bsv JOIN betas b ON b.id=bsv.beta_id JOIN beta_scripts bs ON bs.beta_id=b.id
|
FROM beta_servers bsv JOIN betas b ON b.id=bsv.beta_id
|
||||||
|
JOIN beta_server_scripts bs ON bs.beta_id=b.id AND bs.server=bsv.server
|
||||||
JOIN scripts s ON s.id=bs.script_id WHERE bsv.server_deleted_at IS NULL AND b.state IN ('running','ready-promote')
|
JOIN scripts s ON s.id=bs.script_id WHERE bsv.server_deleted_at IS NULL AND b.state IN ('running','ready-promote')
|
||||||
${serverName === undefined ? '' : 'AND bsv.server = ?'}`).all(...bindings) as
|
${serverName === undefined ? '' : 'AND bsv.server = ?'}`).all(...bindings) as
|
||||||
{ server: string; target: string; betaId: number }[]) {
|
{ server: string; target: string; betaId: number }[]) {
|
||||||
|
|||||||
@@ -158,8 +158,16 @@ async function advancePlan(stored: StoredPlan): Promise<void> {
|
|||||||
}
|
}
|
||||||
const registry = db().prepare('SELECT server AS name, game, map FROM beta_test_coverage WHERE beta_id = ?').all(id) as Array<{ name: string; game: string | null; map: string | null }>;
|
const registry = db().prepare('SELECT server AS name, game, map FROM beta_test_coverage WHERE beta_id = ?').all(id) as Array<{ name: string; game: string | null; map: string | null }>;
|
||||||
// All waves must be activated, even when the final coverage rule is a quorum.
|
// All waves must be activated, even when the final coverage rule is a quorum.
|
||||||
|
// A passing server proves only its assigned files. A quorum or coverage
|
||||||
|
// group must not pass an unrelated game/map's untested bundle member.
|
||||||
const ready = !queued.length && updated.every(s => !!currentRun(id, s.server)?.observing_at)
|
const ready = !queued.length && updated.every(s => !!currentRun(id, s.server)?.observing_at)
|
||||||
&& coveragePassed(plan, updated.map(s => ({ ...registry.find(r => r.name === s.server)!, passed: s.stage === 'ready' })));
|
&& coveragePassed(plan, updated.map(s => ({ ...registry.find(r => r.name === s.server)!, passed: s.stage === 'ready' })))
|
||||||
|
&& !db().prepare(`SELECT 1 FROM beta_scripts files WHERE files.beta_id = ? AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM beta_server_scripts members JOIN beta_servers servers
|
||||||
|
ON servers.beta_id = members.beta_id AND servers.server = members.server
|
||||||
|
WHERE members.beta_id = files.beta_id AND members.script_id = files.script_id
|
||||||
|
AND servers.server_deleted_at IS NULL AND servers.stage = 'ready'
|
||||||
|
) LIMIT 1`).get(id);
|
||||||
betaState(id, ready);
|
betaState(id, ready);
|
||||||
setOutcome(id, ready ? 'passed' : 'active', null);
|
setOutcome(id, ready ? 'passed' : 'active', null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, betaPlanIssues, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
|
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, betaPlanIssues, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
|
||||||
import { coverageSummary, needsGameTelemetry, presetDescriptions, requirementCategory, requirementSummary } from '$lib/beta-plan-summary.js';
|
import { coverageSummary, needsGameTelemetry, presetDescriptions, requirementCategory, requirementSummary } from '$lib/beta-plan-summary.js';
|
||||||
let { plan = $bindable<BetaPlan>(), serverCount }: { plan: BetaPlan; serverCount?: number } = $props();
|
let { plan = $bindable<BetaPlan>(), serverCount, allServersRequired = false }: { plan: BetaPlan; serverCount?: number; allServersRequired?: boolean } = $props();
|
||||||
let validationRequested = $state(false), coverageOpen = $state(false), deadlineOpen = $state(false);
|
let validationRequested = $state(false), coverageOpen = $state(false), deadlineOpen = $state(false);
|
||||||
const issues = $derived(validationRequested ? betaPlanIssues(plan, serverCount) : []);
|
const issues = $derived(validationRequested ? betaPlanIssues(plan, serverCount) : []);
|
||||||
const fieldError = (field: string) => issues.find(issue => issue.field === field)?.message;
|
const fieldError = (field: string) => issues.find(issue => issue.field === field)?.message;
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
try {
|
try {
|
||||||
// Validation returns an independent plain object, even when the source is a Svelte proxy.
|
// Validation returns an independent plain object, even when the source is a Svelte proxy.
|
||||||
plan = parseBetaPlan(preset.plan);
|
plan = parseBetaPlan(preset.plan);
|
||||||
|
if (allServersRequired) plan.scope = 'all';
|
||||||
appliedPresetName = name;
|
appliedPresetName = name;
|
||||||
choosingPreset = false;
|
choosingPreset = false;
|
||||||
editingId = ''; error = '';
|
editingId = ''; error = '';
|
||||||
@@ -178,15 +179,19 @@
|
|||||||
<summary><span>Coverage & waves<small>{coverageSummary(plan)} · {waveSummary}</small></span><span class="edit-hint">Configure</span></summary>
|
<summary><span>Coverage & waves<small>{coverageSummary(plan)} · {waveSummary}</small></span><span class="edit-hint">Configure</span></summary>
|
||||||
<div class="policy-body">
|
<div class="policy-body">
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
|
{#if allServersRequired}
|
||||||
|
<p>Every requested instance must pass. Change the instance counts in the Servers step.</p>
|
||||||
|
{:else}
|
||||||
<label>Qualifying servers<select bind:value={plan.scope}>
|
<label>Qualifying servers<select bind:value={plan.scope}>
|
||||||
<option value="all">Every selected server</option><option value="quorum">At least N selected servers</option>
|
<option value="all">Every selected server</option><option value="quorum">At least N selected servers</option>
|
||||||
<option value="map">At least one per selected map</option><option value="game">At least one per selected game</option>
|
<option value="map">At least one per selected map</option><option value="game">At least one per selected game</option>
|
||||||
<option value="map-game">At least one per selected game/map combination</option>
|
<option value="map-game">At least one per selected game/map combination</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
{#if plan.scope === 'quorum'}<label>Required servers<input id="plan-quorum" aria-invalid={!!fieldError('quorum')} aria-describedby="error-quorum" type="number" min="1" max={serverCount ?? 1000} step="1" bind:value={plan.quorum} required /><span class="error" id="error-quorum">{fieldError('quorum') ?? `${serverCount ?? 0} servers selected`}</span></label>{/if}
|
{#if plan.scope === 'quorum'}<label>Required servers<input id="plan-quorum" aria-invalid={!!fieldError('quorum')} aria-describedby="error-quorum" type="number" min="1" max={serverCount ?? 1000} step="1" bind:value={plan.quorum} required /><span class="error" id="error-quorum">{fieldError('quorum') ?? `${serverCount ?? 0} servers selected`}</span></label>{/if}
|
||||||
|
{/if}
|
||||||
<label>Servers per wave (0 = all at once)<input id="plan-waveSize" aria-invalid={!!fieldError('waveSize')} aria-describedby="error-waveSize" type="number" min="0" step="1" bind:value={plan.waveSize} required /><span class="error" id="error-waveSize">{fieldError('waveSize') ?? ''}</span></label>
|
<label>Servers per wave (0 = all at once)<input id="plan-waveSize" aria-invalid={!!fieldError('waveSize')} aria-describedby="error-waveSize" type="number" min="0" step="1" bind:value={plan.waveSize} required /><span class="error" id="error-waveSize">{fieldError('waveSize') ?? ''}</span></label>
|
||||||
</div>
|
</div>
|
||||||
<p>Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing. Restarts always wait for empty servers.</p>
|
<p>Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing, and every file must pass on at least one matching instance. Restarts always wait for empty servers.</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="policy" bind:open={deadlineOpen}>
|
<details class="policy" bind:open={deadlineOpen}>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { StoredPlan } from '$lib/server/fleet/beta-tests.js';
|
|||||||
import { kick } from '$lib/server/pipeline/worker.js';
|
import { kick } from '$lib/server/pipeline/worker.js';
|
||||||
import { parseBetaPlan, type BetaPlan } from '$lib/beta-plan.js';
|
import { parseBetaPlan, type BetaPlan } from '$lib/beta-plan.js';
|
||||||
import { betaListPage } from '$lib/server/fleet/beta-pages.js';
|
import { betaListPage } from '$lib/server/fleet/beta-pages.js';
|
||||||
|
import { parseBetaInstanceCounts } from '$lib/server/fleet/beta-selection.js';
|
||||||
|
|
||||||
export async function GET({ url }: { url: URL }) {
|
export async function GET({ url }: { url: URL }) {
|
||||||
const state = url.searchParams.get('state');
|
const state = url.searchParams.get('state');
|
||||||
@@ -47,6 +48,7 @@ export async function POST({ request }: { request: Request }) {
|
|||||||
artifacts?: Array<{ id?: number; versionId?: number }>;
|
artifacts?: Array<{ id?: number; versionId?: number }>;
|
||||||
scripts?: Array<{ scriptId?: number; scriptVersionId?: number }>;
|
scripts?: Array<{ scriptId?: number; scriptVersionId?: number }>;
|
||||||
servers?: string[];
|
servers?: string[];
|
||||||
|
instancesPerGame?: Record<string, number>;
|
||||||
waitForHealth?: boolean;
|
waitForHealth?: boolean;
|
||||||
waitForMatch?: boolean;
|
waitForMatch?: boolean;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -57,29 +59,35 @@ export async function POST({ request }: { request: Request }) {
|
|||||||
// Normalize into the openBeta shape. Both fields feed the same list.
|
// Normalize into the openBeta shape. Both fields feed the same list.
|
||||||
const rawEntries: Array<{ scriptId?: number; scriptVersionId?: number }> = [];
|
const rawEntries: Array<{ scriptId?: number; scriptVersionId?: number }> = [];
|
||||||
if (Array.isArray(body.artifacts)) {
|
if (Array.isArray(body.artifacts)) {
|
||||||
for (const a of body.artifacts) rawEntries.push({ scriptId: a.id, scriptVersionId: a.versionId });
|
for (const a of body.artifacts) rawEntries.push({ scriptId: a?.id, scriptVersionId: a?.versionId });
|
||||||
}
|
}
|
||||||
if (Array.isArray(body.scripts)) {
|
if (Array.isArray(body.scripts)) {
|
||||||
for (const s of body.scripts) rawEntries.push({ scriptId: s.scriptId, scriptVersionId: s.scriptVersionId });
|
for (const s of body.scripts) rawEntries.push({ scriptId: s?.scriptId, scriptVersionId: s?.scriptVersionId });
|
||||||
}
|
}
|
||||||
if (rawEntries.length === 0 || !Array.isArray(body.servers)) {
|
if (rawEntries.length === 0) {
|
||||||
return json(
|
return json(
|
||||||
{ ok: false, error: 'artifacts[]/scripts[] (non-empty) and servers[] are required' },
|
{ ok: false, error: 'artifacts[]/scripts[] must contain at least one file' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (body.servers.length === 0) {
|
if ((body.servers !== undefined) === (body.instancesPerGame !== undefined)) {
|
||||||
return json({ ok: false, error: 'at least one server is required' }, { status: 400 });
|
return json({ ok: false, error: 'Provide either instancesPerGame or servers, exclusively' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (body.instancesPerGame !== undefined) {
|
||||||
|
try { body.instancesPerGame = parseBetaInstanceCounts(body.instancesPerGame); }
|
||||||
|
catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 400 }); }
|
||||||
|
} else if (!Array.isArray(body.servers) || !body.servers.length || !body.servers.every(name => typeof name === 'string' && name.trim())) {
|
||||||
|
return json({ ok: false, error: 'servers must contain at least one server name' }, { status: 400 });
|
||||||
}
|
}
|
||||||
const normalized: Array<{ scriptId: number; scriptVersionId: number }> = [];
|
const normalized: Array<{ scriptId: number; scriptVersionId: number }> = [];
|
||||||
for (const s of rawEntries) {
|
for (const s of rawEntries) {
|
||||||
if (typeof s.scriptId !== 'number' || typeof s.scriptVersionId !== 'number') {
|
if (!Number.isSafeInteger(s.scriptId) || !Number.isSafeInteger(s.scriptVersionId) || s.scriptId! < 1 || s.scriptVersionId! < 1) {
|
||||||
return json(
|
return json(
|
||||||
{ ok: false, error: 'each entry needs scriptId/id and scriptVersionId/versionId' },
|
{ ok: false, error: 'each entry needs scriptId/id and scriptVersionId/versionId' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
normalized.push({ scriptId: s.scriptId, scriptVersionId: s.scriptVersionId });
|
normalized.push({ scriptId: s.scriptId!, scriptVersionId: s.scriptVersionId! });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -90,6 +98,7 @@ export async function POST({ request }: { request: Request }) {
|
|||||||
testPlan,
|
testPlan,
|
||||||
scripts: normalized,
|
scripts: normalized,
|
||||||
servers: body.servers,
|
servers: body.servers,
|
||||||
|
instancesPerGame: body.instancesPerGame,
|
||||||
waitForHealth: body.waitForHealth ?? true,
|
waitForHealth: body.waitForHealth ?? true,
|
||||||
waitForMatch: body.waitForMatch ?? false,
|
waitForMatch: body.waitForMatch ?? false,
|
||||||
notes: body.notes
|
notes: body.notes
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
import { defaultPlan, parseBetaPlan, betaPlanIssues, type BetaPlan } from '$lib/beta-plan.js';
|
import { defaultPlan, parseBetaPlan, betaPlanIssues, type BetaPlan } from '$lib/beta-plan.js';
|
||||||
import { betaDraftKey, readBetaDraft, compatibleBetaServers, type BetaDraft } from '$lib/beta-draft.js';
|
import { betaDraftKey, readBetaDraft, compatibleBetaServers, betaScopeMatches, betaCandidateServers, betaApplicableGames, betaInstanceCounts, type BetaDraft } from '$lib/beta-draft.js';
|
||||||
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
|
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
|
||||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||||
import { parseBetaSelection, mapLabel } from '$lib/script-library.js';
|
import { parseBetaSelection, mapLabel } from '$lib/script-library.js';
|
||||||
@@ -28,6 +28,8 @@
|
|||||||
let scripts = $state<FileRow[]>([]), detailById = $state<Record<number,FileDetail>>({}), nextKey=0;
|
let scripts = $state<FileRow[]>([]), detailById = $state<Record<number,FileDetail>>({}), nextKey=0;
|
||||||
let picks = $state<Pick[]>([{key:nextKey++,scriptId:null,versionId:null}]);
|
let picks = $state<Pick[]>([{key:nextKey++,scriptId:null,versionId:null}]);
|
||||||
let pickedServers = $state<Set<string>>(new Set()), testPlan = $state<BetaPlan>(defaultPlan());
|
let pickedServers = $state<Set<string>>(new Set()), testPlan = $state<BetaPlan>(defaultPlan());
|
||||||
|
let selectionMode = $state<BetaDraft['selectionMode']>('automatic');
|
||||||
|
let instancesPerGame = $state<BetaDraft['instancesPerGame']>({});
|
||||||
let step = $state(0), stepHeading = $state<HTMLHeadingElement>(), planBuilder = $state<BetaPlanBuilder>();
|
let step = $state(0), stepHeading = $state<HTMLHeadingElement>(), planBuilder = $state<BetaPlanBuilder>();
|
||||||
let loading = $state(true), openingBeta = $state(false), flash = $state(''), notice = $state('');
|
let loading = $state(true), openingBeta = $state(false), flash = $state(''), notice = $state('');
|
||||||
let availableDraft = $state<BetaDraft|null>(null), draftReady = $state(false), storageUnavailable = $state(false), allowLeave = false;
|
let availableDraft = $state<BetaDraft|null>(null), draftReady = $state(false), storageUnavailable = $state(false), allowLeave = false;
|
||||||
@@ -41,10 +43,29 @@
|
|||||||
const fleetServers = $derived($sharedFleet);
|
const fleetServers = $derived($sharedFleet);
|
||||||
const eligibleFiles = $derived(scripts.filter(file=>file.runningBetaId===null && file.latestVersion!==null));
|
const eligibleFiles = $derived(scripts.filter(file=>file.runningBetaId===null && file.latestVersion!==null));
|
||||||
const filesReady = $derived(picks.length>0 && picks.every(p=>p.scriptId!==null && p.versionId!==null && versionsFor(p).some(v=>v.id===p.versionId)));
|
const filesReady = $derived(picks.length>0 && picks.every(p=>p.scriptId!==null && p.versionId!==null && versionsFor(p).some(v=>v.id===p.versionId)));
|
||||||
const dirty = $derived(!!betaLabel.trim() || picks.some(p=>p.scriptId!==null) || pickedServers.size>0 || JSON.stringify(testPlan)!==JSON.stringify(defaultPlan()));
|
const dirty = $derived(!!betaLabel.trim() || picks.some(p=>p.scriptId!==null) || pickedServers.size>0 || selectionMode==='manual' || Object.values(instancesPerGame).some(count=>count!==1) || JSON.stringify(testPlan)!==JSON.stringify(defaultPlan()));
|
||||||
const candidateServers = $derived.by(()=>{
|
const selectedFiles = $derived(picks.map(p=>p.scriptId===null?null:detailById[p.scriptId]?.script).filter((file):file is FileRow=>!!file));
|
||||||
const scopes=picks.map(p=>p.scriptId===null?null:detailById[p.scriptId]?.script).filter((file):file is FileRow=>!!file);
|
const candidates = $derived(betaCandidateServers(selectedFiles,fleetServers));
|
||||||
return scopes.length ? fleetServers.filter(server=>scopes.every(file=>!file.scope_type || (file.scope_type==='game'?server.game:server.map)===file.scope_value)).map(server=>server.name) : [];
|
const candidateServers = $derived(candidates.map(server=>server.name));
|
||||||
|
const applicableGames = $derived(betaApplicableGames(selectedFiles,fleetServers));
|
||||||
|
const requestedCounts = $derived(betaInstanceCounts(applicableGames,instancesPerGame));
|
||||||
|
const automaticCounts = $derived(Object.fromEntries(Object.entries(requestedCounts).filter((entry):entry is [string,number]=>typeof entry[1]==='number' && entry[1]>0)));
|
||||||
|
const serverCount = $derived(selectionMode==='manual' ? pickedServers.size : Object.values(automaticCounts).reduce((total,count)=>total+count,0));
|
||||||
|
const serverSelectionIssue = $derived.by(()=>{
|
||||||
|
if(selectionMode==='automatic') {
|
||||||
|
if(!applicableGames.length)return 'The selected files have no registered servers in a supported game.';
|
||||||
|
if(Object.values(requestedCounts).some(count=>count===null || !Number.isInteger(count) || count<0 || count>1000))return 'Enter a whole number from 0 to 1,000 for each game.';
|
||||||
|
if(!serverCount)return 'Request at least one test instance.';
|
||||||
|
const missing=selectedFiles.find(file=>!candidates.some(server=>betaScopeMatches(file,server) && !!server.game && (automaticCounts[server.game]??0)>0));
|
||||||
|
if(missing)return `${missing.name} needs at least one instance in a matching game and scope.`;
|
||||||
|
const short=applicableGames.find(game=>(automaticCounts[game]??0)>candidates.filter(server=>server.game===game).length);
|
||||||
|
if(short)return `${gameLabel(short)} has fewer compatible registered servers than requested. Lower its instance count or add servers.`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if(!pickedServers.size)return 'Select at least one compatible test server.';
|
||||||
|
if([...pickedServers].some(name=>!candidateServers.includes(name)))return 'Recheck the selected files and servers before starting.';
|
||||||
|
const missing=selectedFiles.find(file=>!candidates.some(server=>pickedServers.has(server.name)&&betaScopeMatches(file,server)));
|
||||||
|
return missing ? `Select at least one server matching ${missing.name}. Every file needs a test participant.` : '';
|
||||||
});
|
});
|
||||||
const visibleServers = $derived(fleetServers.filter(server=>candidateServers.includes(server.name)).filter(server=>{
|
const visibleServers = $derived(fleetServers.filter(server=>candidateServers.includes(server.name)).filter(server=>{
|
||||||
const identity=fleetIdentity(server), fresh=freshServer(server,now);
|
const identity=fleetIdentity(server), fresh=freshServer(server,now);
|
||||||
@@ -52,10 +73,11 @@
|
|||||||
&& `${server.name} ${identity.title} ${identity.map} ${identity.game}`.toLowerCase().includes(search.toLowerCase());
|
&& `${server.name} ${identity.title} ${identity.map} ${identity.game}`.toLowerCase().includes(search.toLowerCase());
|
||||||
}));
|
}));
|
||||||
const groups = $derived([...new Set(visibleServers.map(server=>fleetIdentity(server).group))].map(group=>({key:group,servers:visibleServers.filter(server=>fleetIdentity(server).group===group)})));
|
const groups = $derived([...new Set(visibleServers.map(server=>fleetIdentity(server).group))].map(group=>({key:group,servers:visibleServers.filter(server=>fleetIdentity(server).group===group)})));
|
||||||
const forwardReason = $derived(step>0 && $fleetError ? 'Server eligibility could not be verified. Retry servers before continuing; your selections are preserved.' : step===0 ? !filesReady ? 'Choose an available file and exact version in every row.' : '' : step===1 ? !pickedServers.size ? 'Select at least one compatible test server.' : '' : step===2 ? !testPlan.requirements.length ? 'Add at least one requirement to continue.' : '' : !filesReady || !pickedServers.size || [...pickedServers].some(name=>!candidateServers.includes(name)) ? 'Recheck the selected files and servers before starting.' : '');
|
const forwardReason = $derived(step>0 && $fleetError ? 'Server eligibility could not be verified. Retry servers before continuing; your selections are preserved.' : step===0 ? !filesReady ? 'Choose an available file and exact version in every row.' : '' : step===1 ? serverSelectionIssue : step===2 ? !testPlan.requirements.length ? 'Add at least one requirement to continue.' : '' : !filesReady ? 'Recheck the selected files before starting.' : serverSelectionIssue);
|
||||||
|
|
||||||
function versionsFor(pick:Pick) { return pick.scriptId===null ? [] : (detailById[pick.scriptId]?.versions??[]).filter(v=>v.id!==detailById[pick.scriptId!]?.script.current_version_id); }
|
function versionsFor(pick:Pick) { return pick.scriptId===null ? [] : (detailById[pick.scriptId]?.versions??[]).filter(v=>v.id!==detailById[pick.scriptId!]?.script.current_version_id); }
|
||||||
function eligibleFor(index:number) { return eligibleFiles.filter(file=>!picks.some((p,i)=>i!==index&&p.scriptId===file.id)); }
|
function eligibleFor(index:number) { return eligibleFiles.filter(file=>!picks.some((p,i)=>i!==index&&p.scriptId===file.id)); }
|
||||||
|
function gameLabel(game:string) { return game==='t5'?'Black Ops I (T5)':game==='t6'?'Black Ops II (T6)':game; }
|
||||||
function scopeText(file:FileRow) { return !file.scope_type ? 'Any registered server' : file.scope_type==='map' ? `Map: ${mapLabel(file.scope_value??'')}` : `Game: ${file.scope_value==='t5'?'Black Ops I':file.scope_value==='t6'?'Black Ops II':file.scope_value}`; }
|
function scopeText(file:FileRow) { return !file.scope_type ? 'Any registered server' : file.scope_type==='map' ? `Map: ${mapLabel(file.scope_value??'')}` : `Game: ${file.scope_value==='t5'?'Black Ops I':file.scope_value==='t6'?'Black Ops II':file.scope_value}`; }
|
||||||
function scopeCount(file:FileRow) { return fleetServers.filter(server=>!file.scope_type || (file.scope_type==='game'?server.game:server.map)===file.scope_value).length; }
|
function scopeCount(file:FileRow) { return fleetServers.filter(server=>!file.scope_type || (file.scope_type==='game'?server.game:server.map)===file.scope_value).length; }
|
||||||
function reconcileSelection() {
|
function reconcileSelection() {
|
||||||
@@ -63,7 +85,7 @@
|
|||||||
// authoritative enough to remove selections from the saved draft.
|
// authoritative enough to remove selections from the saved draft.
|
||||||
if($fleetError || picks.every(p=>p.scriptId===null) || picks.some(p=>p.scriptId!==null && !detailById[p.scriptId]))return;
|
if($fleetError || picks.every(p=>p.scriptId===null) || picks.some(p=>p.scriptId!==null && !detailById[p.scriptId]))return;
|
||||||
const result=compatibleBetaServers(pickedServers,candidateServers);
|
const result=compatibleBetaServers(pickedServers,candidateServers);
|
||||||
if(result.removed.length) { pickedServers=result.kept; notice=`Kept ${result.kept.size} compatible selections. Removed ${result.removed.length} servers that no longer match every file: ${result.removed.join(', ')}.`; }
|
if(result.removed.length) { pickedServers=result.kept; notice=`Kept ${result.kept.size} compatible selections. Removed ${result.removed.length} servers that no longer match any file: ${result.removed.join(', ')}.`; }
|
||||||
}
|
}
|
||||||
async function loadSelections(selections:BetaDraft['picks']) {
|
async function loadSelections(selections:BetaDraft['picks']) {
|
||||||
const warnings:string[]=[];
|
const warnings:string[]=[];
|
||||||
@@ -110,6 +132,7 @@
|
|||||||
if(source.test?.plan)testPlan=parseBetaPlan(source.test.plan);
|
if(source.test?.plan)testPlan=parseBetaPlan(source.test.plan);
|
||||||
betaLabel=source.beta.notes ?? '';
|
betaLabel=source.beta.notes ?? '';
|
||||||
pickedServers=new Set(source.servers.map((s:{server:string})=>s.server));
|
pickedServers=new Set(source.servers.map((s:{server:string})=>s.server));
|
||||||
|
selectionMode='manual';
|
||||||
await loadSelections(source.scripts.map((file:{id:number;version:{id:number}|null})=>({scriptId:file.id,versionId:file.version?.id??null})));
|
await loadSelections(source.scripts.map((file:{id:number;version:{id:number}|null})=>({scriptId:file.id,versionId:file.version?.id??null})));
|
||||||
reconcileSelection(); notice='Copied the exact file versions and test plan. Review availability, server selection, and the testing window before starting.'; draftReady=true;
|
reconcileSelection(); notice='Copied the exact file versions and test plan. Review availability, server selection, and the testing window before starting.'; draftReady=true;
|
||||||
} else if(selections.length) { await loadSelections(selections); draftReady=true; }
|
} else if(selections.length) { await loadSelections(selections); draftReady=true; }
|
||||||
@@ -120,22 +143,23 @@
|
|||||||
async function resumeDraft() {
|
async function resumeDraft() {
|
||||||
if(!availableDraft)return;
|
if(!availableDraft)return;
|
||||||
const draft=availableDraft; loading=true; flash=''; notice='';
|
const draft=availableDraft; loading=true; flash=''; notice='';
|
||||||
try { testPlan=draft.testPlan; betaLabel=typeof draft.label==='string'?draft.label:''; step=draft.step; pickedServers=new Set(draft.servers); await loadSelections(draft.picks); reconcileSelection(); notice=notice || 'Draft resumed. Exact file versions and compatible servers were rechecked.'; }
|
try { testPlan=draft.testPlan; selectionMode=draft.selectionMode; instancesPerGame=draft.instancesPerGame; betaLabel=typeof draft.label==='string'?draft.label:''; step=draft.step; pickedServers=new Set(draft.servers); await loadSelections(draft.picks); reconcileSelection(); notice=notice || 'Draft resumed. Exact file versions and compatible servers were rechecked.'; }
|
||||||
finally { availableDraft=null; loading=false; draftReady=true; }
|
finally { availableDraft=null; loading=false; draftReady=true; }
|
||||||
}
|
}
|
||||||
function discardDraft() {
|
function discardDraft() {
|
||||||
const useLinked=!!availableDraft && hasLinkedSetup;
|
const useLinked=!!availableDraft && hasLinkedSetup;
|
||||||
availableDraft=null; picks=[{key:nextKey++,scriptId:null,versionId:null}]; pickedServers=new Set(); testPlan=defaultPlan(); betaLabel=''; step=0; notice='Draft discarded.';
|
availableDraft=null; picks=[{key:nextKey++,scriptId:null,versionId:null}]; pickedServers=new Set(); selectionMode='automatic'; instancesPerGame={}; testPlan=defaultPlan(); betaLabel=''; step=0; notice='Draft discarded.';
|
||||||
try{localStorage.removeItem(betaDraftKey);}catch{storageUnavailable=true;}
|
try{localStorage.removeItem(betaDraftKey);}catch{storageUnavailable=true;}
|
||||||
draftReady=true;
|
draftReady=true;
|
||||||
if(useLinked)void initialize(true);
|
if(useLinked)void initialize(true);
|
||||||
}
|
}
|
||||||
$effect(()=>{
|
$effect(()=>{
|
||||||
if(!draftReady || loading || openingBeta)return;
|
if(!draftReady || loading || openingBeta)return;
|
||||||
const draft:BetaDraft={version:1,savedAt:new Date().toISOString(),step,label:betaLabel,picks:picks.map(({scriptId,versionId})=>({scriptId,versionId})),servers:[...pickedServers],testPlan:JSON.parse(JSON.stringify(testPlan))};
|
const draft:BetaDraft={version:1,savedAt:new Date().toISOString(),step,label:betaLabel,picks:picks.map(({scriptId,versionId})=>({scriptId,versionId})),servers:[...pickedServers],selectionMode,instancesPerGame:{...instancesPerGame,...requestedCounts},testPlan:JSON.parse(JSON.stringify(testPlan))};
|
||||||
if(dirty) { try{localStorage.setItem(betaDraftKey,JSON.stringify(draft));storageUnavailable=false;}catch{storageUnavailable=true;} }
|
if(dirty) { try{localStorage.setItem(betaDraftKey,JSON.stringify(draft));storageUnavailable=false;}catch{storageUnavailable=true;} }
|
||||||
});
|
});
|
||||||
$effect(()=>{ if(draftReady && !loading && !loadingVersions.size) { candidateServers; reconcileSelection(); } });
|
$effect(()=>{ if(draftReady && !loading && !loadingVersions.size) { candidateServers; reconcileSelection(); } });
|
||||||
|
$effect(()=>{ if(selectionMode==='automatic' && testPlan.scope!=='all')testPlan={...testPlan,scope:'all'}; });
|
||||||
beforeNavigate(navigation=>{
|
beforeNavigate(navigation=>{
|
||||||
if(!dirty || !storageUnavailable || allowLeave || openingBeta)return;
|
if(!dirty || !storageUnavailable || allowLeave || openingBeta)return;
|
||||||
if(navigation.willUnload){navigation.cancel();return;}
|
if(navigation.willUnload){navigation.cancel();return;}
|
||||||
@@ -165,10 +189,10 @@
|
|||||||
}
|
}
|
||||||
async function openBeta(event:Event) {
|
async function openBeta(event:Event) {
|
||||||
event.preventDefault();if(step!==3||forwardReason||openingBeta)return;
|
event.preventDefault();if(step!==3||forwardReason||openingBeta)return;
|
||||||
if(betaPlanIssues(testPlan,pickedServers.size).length){await move(2);await planBuilder?.validate();return;}
|
if(betaPlanIssues(testPlan,serverCount).length){await move(2);await planBuilder?.validate();return;}
|
||||||
openingBeta=true;flash='';
|
openingBeta=true;flash='';
|
||||||
try {
|
try {
|
||||||
const response=await fetch('/api/betas',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scripts:picks.map(p=>({scriptId:p.scriptId,scriptVersionId:p.versionId})),servers:[...pickedServers],testPlan:parseBetaPlan(testPlan),notes:betaLabel.trim()||undefined,waitForHealth:true,waitForMatch:false})}), body=await response.json();
|
const response=await fetch('/api/betas',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scripts:picks.map(p=>({scriptId:p.scriptId,scriptVersionId:p.versionId})),...(selectionMode==='automatic'?{instancesPerGame:automaticCounts}:{servers:[...pickedServers]}),testPlan:parseBetaPlan(testPlan),notes:betaLabel.trim()||undefined,waitForHealth:true,waitForMatch:false})}), body=await response.json();
|
||||||
if(!response.ok||!body.ok)throw new Error(body.error??'Could not start beta. Review the selected versions and servers.');
|
if(!response.ok||!body.ok)throw new Error(body.error??'Could not start beta. Review the selected versions and servers.');
|
||||||
draftReady=false;try{localStorage.removeItem(betaDraftKey);}catch{}allowLeave=true;
|
draftReady=false;try{localStorage.removeItem(betaDraftKey);}catch{}allowLeave=true;
|
||||||
await goto(contextHref(`/betas/${body.result.betaId}`, safeReturnHref($page.url.searchParams.get('returnTo'), '/betas')));
|
await goto(contextHref(`/betas/${body.result.betaId}`, safeReturnHref($page.url.searchParams.get('returnTo'), '/betas')));
|
||||||
@@ -180,12 +204,12 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: 'New beta' }]} />
|
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: 'New beta' }]} />
|
||||||
<BackLink fallback="/betas" />
|
<BackLink fallback="/betas" />
|
||||||
<SectionHeader title="New beta" subtitle="Choose exact file versions and test servers, then review before starting." />
|
<SectionHeader title="New beta" subtitle="Test files across games in one beta. Choose how many instances need to pass." />
|
||||||
{#if flash}<div class="ui-error" role="alert">{flash}{#if !scripts.length}<Button size="sm" onclick={()=>initialize()}>Retry</Button>{/if}</div>{/if}
|
{#if flash}<div class="ui-error" role="alert">{flash}{#if !scripts.length}<Button size="sm" onclick={()=>initialize()}>Retry</Button>{/if}</div>{/if}
|
||||||
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry servers</Button></div>{/if}
|
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry servers</Button></div>{/if}
|
||||||
{#if notice}<p role="status" class="notice">{notice}</p>{/if}
|
{#if notice}<p role="status" class="notice">{notice}</p>{/if}
|
||||||
{#if loading}<EmptyState title="Loading beta setup…"/>
|
{#if loading}<EmptyState title="Loading beta setup…"/>
|
||||||
{:else if availableDraft}<section class="ui-panel draft"><h2>Resume unfinished beta?</h2><p>Saved {$timeFormat.dateTime(availableDraft.savedAt)} · {availableDraft.picks.filter(p=>p.scriptId!==null).length} files · {availableDraft.servers.length} selected servers. Versions and eligibility will be rechecked.</p><div class="actions"><Button variant="primary" onclick={resumeDraft}>Resume draft</Button><Button onclick={discardDraft}>{hasLinkedSetup ? 'Discard draft and use linked files' : 'Discard draft'}</Button></div></section>
|
{:else if availableDraft}<section class="ui-panel draft"><h2>Resume unfinished beta?</h2><p>Saved {$timeFormat.dateTime(availableDraft.savedAt)} · {availableDraft.picks.filter(p=>p.scriptId!==null).length} files · {availableDraft.selectionMode==='automatic'?'Automatic server selection':`${availableDraft.servers.length} selected servers`}. Versions and eligibility will be rechecked.</p><div class="actions"><Button variant="primary" onclick={resumeDraft}>Resume draft</Button><Button onclick={discardDraft}>{hasLinkedSetup ? 'Discard draft and use linked files' : 'Discard draft'}</Button></div></section>
|
||||||
{:else if !eligibleFiles.length && !flash}<EmptyState title="No files ready to test" description="Upload a file version or finish its active beta, then return here."/><Button href="/scripts">Open Library</Button>
|
{:else if !eligibleFiles.length && !flash}<EmptyState title="No files ready to test" description="Upload a file version or finish its active beta, then return here."/><Button href="/scripts">Open Library</Button>
|
||||||
{:else if scripts.length}
|
{:else if scripts.length}
|
||||||
<ol class="steps" aria-label="Beta setup progress">{#each steps as label,index}<li aria-current={step===index?'step':undefined} class:current={step===index}><span>{index+1}</span>{label}</li>{/each}</ol>
|
<ol class="steps" aria-label="Beta setup progress">{#each steps as label,index}<li aria-current={step===index?'step':undefined} class:current={step===index}><span>{index+1}</span>{label}</li>{/each}</ol>
|
||||||
@@ -201,30 +225,56 @@
|
|||||||
{#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}<p class="scope">{scopeText(file)} · {scopeCount(file)} eligible servers · <a href={`/${file.kind==='baseline'?'configs':file.kind+'s'}/${file.id}`}>Inspect file and versions</a>{#if !loadingVersions.has(pick.key)&&!versionsFor(pick).length}<br/>No unpromoted versions. Upload a new version from the file page.{/if}</p>{/if}{/if}
|
{#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}<p class="scope">{scopeText(file)} · {scopeCount(file)} eligible servers · <a href={`/${file.kind==='baseline'?'configs':file.kind+'s'}/${file.id}`}>Inspect file and versions</a>{#if !loadingVersions.has(pick.key)&&!versionsFor(pick).length}<br/>No unpromoted versions. Upload a new version from the file page.{/if}</p>{/if}{/if}
|
||||||
</section>{/each}</div>
|
</section>{/each}</div>
|
||||||
<Button onclick={()=>picks=[...picks,{key:nextKey++,scriptId:null,versionId:null}]} disabled={picks.length>=eligibleFiles.length}>Add file</Button>
|
<Button onclick={()=>picks=[...picks,{key:nextKey++,scriptId:null,versionId:null}]} disabled={picks.length>=eligibleFiles.length}>Add file</Button>
|
||||||
<p class="muted">Test servers must match every file's scope. Changing a file retains compatible server selections.</p>
|
<p class="muted">Combine files for different games in one beta. Each file installs only on servers matching its scope, and every file needs at least one test participant.</p>
|
||||||
{:else if step===1}
|
{:else if step===1}
|
||||||
<p><strong>{pickedServers.size} selected</strong> · {candidateServers.length} compatible servers. Restarts wait for confirmed empty lobbies.</p>
|
{#if selectionMode==='automatic'}
|
||||||
|
<div class="automatic-selection">
|
||||||
|
<h3>Instances that must pass per game</h3>
|
||||||
|
<p>At launch, Electric Cherry chooses compatible servers with the fewest current players. Every requested instance must pass the test plan.</p>
|
||||||
|
<div class="instance-counts">{#each applicableGames as game}
|
||||||
|
<label>{gameLabel(game)}<input type="number" min="0" max="1000" step="1" value={requestedCounts[game]??''} oninput={event=>instancesPerGame={...instancesPerGame,[game]:event.currentTarget.value===''?null:event.currentTarget.valueAsNumber}}/><span class="muted">{candidates.filter(server=>server.game===game).length} compatible registered servers</span></label>
|
||||||
|
{/each}</div>
|
||||||
|
<p class="muted">Servers are selected when you start the beta, using fresh player counts and availability checks. Restarts wait for confirmed empty lobbies. Set a game to 0 only if every file still has a matching participant.</p>
|
||||||
|
</div>
|
||||||
|
{:else}<p><strong>{pickedServers.size} selected</strong> · {candidateServers.length} compatible servers. Restarts wait for confirmed empty lobbies.</p>{/if}
|
||||||
<details><summary>Selected file scopes</summary><ul>{#each picks as pick}{#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}<li>{file.name}: {scopeText(file)} · {scopeCount(file)} eligible servers</li>{/if}{/if}{/each}</ul></details>
|
<details><summary>Selected file scopes</summary><ul>{#each picks as pick}{#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}<li>{file.name}: {scopeText(file)} · {scopeCount(file)} eligible servers</li>{/if}{/if}{/each}</ul></details>
|
||||||
{#if !candidateServers.length}<div class="ui-error">The selected file scopes have no servers in common. Review the scopes above, then go Back to replace or remove a conflicting file.</div>
|
{#if !candidateServers.length}<div class="ui-error">No registered servers match these files. Review their scopes above or register a matching server.</div>{/if}
|
||||||
{:else}
|
<details class="advanced-selection" open={selectionMode==='manual'}><summary>Advanced options · choose specific servers</summary>
|
||||||
|
<div class="advanced-body">
|
||||||
|
<fieldset class="selection-mode"><legend>Server selection</legend>
|
||||||
|
<label><input type="radio" name="server-selection" value="automatic" bind:group={selectionMode}/>Choose least-used servers automatically</label>
|
||||||
|
<label><input type="radio" name="server-selection" value="manual" bind:group={selectionMode}/>Choose specific servers</label>
|
||||||
|
</fieldset>
|
||||||
|
{#if selectionMode==='manual' && candidateServers.length}
|
||||||
<div class="filters"><label>Find servers<input type="search" bind:value={search} placeholder="Name, map, lobby…"/></label><label>Game<select bind:value={serverGame}><option value="">All games</option>{#each [...new Set(fleetServers.map(s=>s.game).filter(Boolean))] as game}<option value={game}>{game==='t5'?'Black Ops I':game==='t6'?'Black Ops II':game}</option>{/each}</select></label><label>Availability<select bind:value={availability}><option value="">All servers</option><option value="empty">Confirmed empty</option><option value="occupied">Occupied</option><option value="unknown">Unknown / stale / offline</option></select></label></div>
|
<div class="filters"><label>Find servers<input type="search" bind:value={search} placeholder="Name, map, lobby…"/></label><label>Game<select bind:value={serverGame}><option value="">All games</option>{#each [...new Set(fleetServers.map(s=>s.game).filter(Boolean))] as game}<option value={game}>{game==='t5'?'Black Ops I':game==='t6'?'Black Ops II':game}</option>{/each}</select></label><label>Availability<select bind:value={availability}><option value="">All servers</option><option value="empty">Confirmed empty</option><option value="occupied">Occupied</option><option value="unknown">Unknown / stale / offline</option></select></label></div>
|
||||||
<div class="actions"><span>{visibleServers.length} visible</span><Button size="sm" onclick={()=>selectVisible(true)} disabled={!visibleServers.length}>Select visible</Button><Button size="sm" onclick={()=>selectVisible(false)} disabled={!visibleServers.some(s=>pickedServers.has(s.name))}>Deselect visible</Button></div>
|
<div class="actions"><span>{visibleServers.length} visible</span><Button size="sm" onclick={()=>selectVisible(true)} disabled={!visibleServers.length}>Select visible</Button><Button size="sm" onclick={()=>selectVisible(false)} disabled={!visibleServers.some(s=>pickedServers.has(s.name))}>Deselect visible</Button></div>
|
||||||
{#each groups as group}<fieldset><legend>{fleetIdentity(group.servers[0]).map} · {group.servers[0].game==='t5'?'Black Ops I':'Black Ops II'}</legend><div class="server-picker">{#each group.servers as server}<label class="server-choice"><input type="checkbox" checked={pickedServers.has(server.name)} onchange={()=>toggleServer(server.name)}/><span><ServerIdentity name={server.name}/><small>{freshServer(server,now)&&server.last_players!==null ? `${server.last_players} players · ${server.last_players===0?'Confirmed empty':'Occupied'}` : `Players unknown · ${server.health}`}{#if server.held.length} · In beta #{server.held[0].betaId}{/if}</small></span></label>{/each}</div></fieldset>{/each}
|
{#each groups as group}<fieldset><legend>{fleetIdentity(group.servers[0]).map} · {group.servers[0].game==='t5'?'Black Ops I':'Black Ops II'}</legend><div class="server-picker">{#each group.servers as server}<label class="server-choice"><input type="checkbox" checked={pickedServers.has(server.name)} onchange={()=>toggleServer(server.name)}/><span><ServerIdentity name={server.name}/><small>{freshServer(server,now)&&server.last_players!==null ? `${server.last_players} players · ${server.last_players===0?'Confirmed empty':'Occupied'}` : `Players unknown · ${server.health}`}{#if server.held.length} · In beta #{server.held[0].betaId}{/if}</small></span></label>{/each}</div></fieldset>{/each}
|
||||||
{#if !visibleServers.length}<p>No servers match these filters. <button type="button" class="text-action" onclick={()=>{search='';serverGame='';availability='';}}>Clear filters</button></p>{/if}
|
{#if !visibleServers.length}<p>No servers match these filters. <button type="button" class="text-action" onclick={()=>{search='';serverGame='';availability='';}}>Clear filters</button></p>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if step===2}<BetaPlanBuilder bind:this={planBuilder} bind:plan={testPlan} serverCount={pickedServers.size}/>
|
</div></details>
|
||||||
{:else}<section class="ui-panel review"><div class="review-heading"><h3>Files and exact versions</h3><Button size="sm" onclick={()=>move(0)}>Edit files</Button></div><ul>{#each picks as pick}<li><strong>{scripts.find(file=>file.id===pick.scriptId)?.name}</strong> · {artifactVersionLabel(versionsFor(pick).find(v=>v.id===pick.versionId))} · {versionsFor(pick).find(v=>v.id===pick.versionId)?.sha256.slice(0,12)}</li>{/each}</ul><div class="review-heading"><h3>Test servers ({pickedServers.size})</h3><Button size="sm" onclick={()=>move(1)}>Edit servers</Button></div><ul>{#each [...pickedServers] as name}<li><ServerIdentity {name}/></li>{/each}</ul><div class="review-heading"><h3>Pass criteria</h3><Button size="sm" onclick={()=>move(2)}>Edit plan</Button></div><p>{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour window, starting when this beta opens.</p><ul>{#each testPlan.requirements as requirement}<li>{requirementSummary(requirement)}</li>{/each}</ul><h3>Waves and failure handling</h3><p>{testPlan.waveSize?`${testPlan.waveSize} servers per wave.`:'Deploy to all selected servers.'} {testPlan.failureAction==='rollback'?'Failed checks trigger automatic rollback.':testPlan.failureAction==='pause'?'Failed checks pause testing.':'Failed checks hold the beta for review.'} {testPlan.rollbackRestart==='when-empty'?'Rollback restarts wait for empty servers.':'Rollback restores files; restart servers manually.'}</p><p>Starting installs the selected versions and restarts test servers when empty. Waiting, pausing, and retrying use the original testing window. Promotion is a separate reviewed action after testing.</p></section>{/if}
|
{:else if step===2}<BetaPlanBuilder bind:this={planBuilder} bind:plan={testPlan} {serverCount} allServersRequired={selectionMode==='automatic'}/>
|
||||||
<div class="ui-sticky-actions"><p id="beta-forward-help" class="muted">{forwardReason}</p><Button onclick={()=>move(step-1)} disabled={step===0||openingBeta}>Back</Button>{#if step<3}<Button variant="primary" onclick={()=>move(step+1)} disabled={!!forwardReason}>Continue</Button>{:else}<Button type="submit" variant="primary" disabled={!!forwardReason||openingBeta} loading={openingBeta}>Start beta on {pickedServers.size} servers</Button>{/if}</div>
|
{:else}<section class="ui-panel review">
|
||||||
|
<div class="review-heading"><h3>Files and exact versions</h3><Button size="sm" onclick={()=>move(0)}>Edit files</Button></div>
|
||||||
|
<ul>{#each picks as pick}<li><strong>{scripts.find(file=>file.id===pick.scriptId)?.name}</strong> · {artifactVersionLabel(versionsFor(pick).find(v=>v.id===pick.versionId))} · {versionsFor(pick).find(v=>v.id===pick.versionId)?.sha256.slice(0,12)}{#if pick.scriptId!==null && detailById[pick.scriptId]}<br/><span class="muted">{scopeText(detailById[pick.scriptId].script)}</span>{/if}</li>{/each}</ul>
|
||||||
|
<div class="review-heading"><h3>Test instances ({serverCount})</h3><Button size="sm" onclick={()=>move(1)}>Edit servers</Button></div>
|
||||||
|
{#if selectionMode==='automatic'}
|
||||||
|
<ul>{#each Object.entries(automaticCounts) as [game,count]}<li><strong>{gameLabel(game)}:</strong> {count} instance{count===1?'':'s'} must pass</li>{/each}</ul>
|
||||||
|
<p>The least-used available servers are chosen when you start the beta. Selection uses current player counts and ensures every file has a matching participant. If there are not enough available servers, the beta will not start.</p>
|
||||||
|
{:else}<ul>{#each [...pickedServers] as name}<li><ServerIdentity {name}/></li>{/each}</ul>{/if}
|
||||||
|
<div class="review-heading"><h3>Pass criteria</h3><Button size="sm" onclick={()=>move(2)}>Edit plan</Button></div><p>{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour window, starting when this beta opens.</p><ul>{#each testPlan.requirements as requirement}<li>{requirementSummary(requirement)}</li>{/each}</ul><h3>Waves and failure handling</h3><p>{testPlan.waveSize?`${testPlan.waveSize} servers per wave.`:'Deploy to all selected servers.'} {testPlan.failureAction==='rollback'?'Failed checks trigger automatic rollback.':testPlan.failureAction==='pause'?'Failed checks pause testing.':'Failed checks hold the beta for review.'} {testPlan.rollbackRestart==='when-empty'?'Rollback restarts wait for empty servers.':'Rollback restores files; restart servers manually.'}</p><p>Starting installs each selected version only on matching test servers and restarts them when empty. Waiting, pausing, and retrying use the original testing window. Promotion is a separate reviewed action after testing.</p>
|
||||||
|
</section>{/if}
|
||||||
|
<div class="ui-sticky-actions"><p id="beta-forward-help" class="muted">{forwardReason}</p><Button onclick={()=>move(step-1)} disabled={step===0||openingBeta}>Back</Button>{#if step<3}<Button variant="primary" onclick={()=>move(step+1)} disabled={!!forwardReason}>Continue</Button>{:else}<Button type="submit" variant="primary" disabled={!!forwardReason||openingBeta} loading={openingBeta}>Start beta on {serverCount} instances</Button>{/if}</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.beta-form input:not([type='checkbox']), .beta-form select { border-color:var(--border-control); }
|
.beta-form input:not([type='checkbox']):not([type='radio']), .beta-form select { border-color:var(--border-control); }
|
||||||
.page,.beta-form { display:grid; gap:24px; } h2,h3,p { margin:0; } h2 { font-size:20px; } h3 { font-size:16px; } p,li { font-size:14px; line-height:1.6; } .muted,.draft-status { color:var(--text-secondary); font-size:13px; }
|
.page,.beta-form { display:grid; gap:24px; } h2,h3,p { margin:0; } h2 { font-size:20px; } h3 { font-size:16px; } p,li { font-size:14px; line-height:1.6; } .muted,.draft-status { color:var(--text-secondary); font-size:13px; }
|
||||||
.steps { display:flex; list-style:none; gap:16px; padding:0; margin:0; flex-wrap:wrap; } .steps li { display:flex; align-items:center; gap:8px; color:var(--text-secondary); } .steps li span { border:1px solid var(--border-strong); border-radius:50%; padding:3px 9px; } .steps .current { color:var(--text-primary); font-weight:600; } .steps .current span { background:var(--accent-primary); color:var(--text-on-accent); }
|
.steps { display:flex; list-style:none; gap:16px; padding:0; margin:0; flex-wrap:wrap; } .steps li { display:flex; align-items:center; gap:8px; color:var(--text-secondary); } .steps li span { border:1px solid var(--border-strong); border-radius:50%; padding:3px 9px; } .steps .current { color:var(--text-primary); font-weight:600; } .steps .current span { background:var(--accent-primary); color:var(--text-on-accent); }
|
||||||
.picks { display:grid; gap:16px; } .pick-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto; gap:12px; align-items:end; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); } .scope { grid-column:1/-1; font-size:13px; color:var(--text-secondary); }
|
.picks { display:grid; gap:16px; } .pick-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto; gap:12px; align-items:end; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); } .scope { grid-column:1/-1; font-size:13px; color:var(--text-secondary); }
|
||||||
label { display:grid; gap:6px; font-size:13px; color:var(--text-secondary); min-width:0; } input:not([type='checkbox']),select { width:100%; min-height:40px; padding:9px 12px; box-sizing:border-box; border:1px solid var(--border-strong); border-radius:6px; color:var(--text-primary); background:var(--surface-sunken); font:inherit; } a,.text-action { color:var(--accent-primary-hover); } .text-action { background:none; border:0; text-decoration:underline; cursor:pointer; }
|
label { display:grid; gap:6px; font-size:13px; color:var(--text-secondary); min-width:0; } input:not([type='checkbox']):not([type='radio']),select { width:100%; min-height:40px; padding:9px 12px; box-sizing:border-box; border:1px solid var(--border-strong); border-radius:6px; color:var(--text-primary); background:var(--surface-sunken); font:inherit; } a,.text-action { color:var(--accent-primary-hover); } .text-action { background:none; border:0; text-decoration:underline; cursor:pointer; }
|
||||||
|
.automatic-selection,.advanced-body { display:grid; gap:16px; } .instance-counts { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(220px,100%),1fr)); gap:16px; } .advanced-selection { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; } .advanced-selection summary { cursor:pointer; font-size:14px; } .advanced-body { margin-top:16px; } .selection-mode { display:grid; gap:12px; } .selection-mode label { display:flex; align-items:center; gap:8px; cursor:pointer; } .selection-mode input { accent-color:var(--accent-primary); }
|
||||||
.filters,.actions,.draft-status,.review-heading { display:flex; align-items:center; flex-wrap:wrap; gap:12px; } .filters { align-items:end; } .filters label { flex:1 1 180px; } .draft-status,.review-heading { justify-content:space-between; } .draft { display:grid; gap:16px; }
|
.filters,.actions,.draft-status,.review-heading { display:flex; align-items:center; flex-wrap:wrap; gap:12px; } .filters { align-items:end; } .filters label { flex:1 1 180px; } .draft-status,.review-heading { justify-content:space-between; } .draft { display:grid; gap:16px; }
|
||||||
fieldset { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; min-width:0; } legend { font-size:14px; padding:0 8px; } .server-picker { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(300px,100%),1fr)); gap:12px; } .server-choice { display:flex; align-items:center; min-height:48px; gap:10px; color:var(--text-primary); cursor:pointer; } .server-choice small { display:block; color:var(--text-secondary); font-size:12px; margin-top:4px; } .server-choice input { accent-color:var(--accent-primary); width:18px; height:18px; flex-shrink:0; }
|
fieldset { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; min-width:0; } legend { font-size:14px; padding:0 8px; } .server-picker { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(300px,100%),1fr)); gap:12px; } .server-choice { display:flex; align-items:center; min-height:48px; gap:10px; color:var(--text-primary); cursor:pointer; } .server-choice small { display:block; color:var(--text-secondary); font-size:12px; margin-top:4px; } .server-choice input { accent-color:var(--accent-primary); width:18px; height:18px; flex-shrink:0; }
|
||||||
.review { display:grid; gap:16px; } .review ul { margin:0; } .review li { margin-bottom:8px; } .notice { padding:12px; border:1px solid var(--border-strong); border-radius:var(--radius-md); } .ui-sticky-actions p { margin-right:auto; flex:1 1 200px; }
|
.review { display:grid; gap:16px; } .review ul { margin:0; } .review li { margin-bottom:8px; } .notice { padding:12px; border:1px solid var(--border-strong); border-radius:var(--radius-md); } .ui-sticky-actions p { margin-right:auto; flex:1 1 200px; }
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
});
|
||||||
|
afterEach(async () => { await fixture.cleanup(); vi.restoreAllMocks(); });
|
||||||
|
|
||||||
|
async function seedBeta(multiple: boolean) {
|
||||||
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const games = multiple ? ['t5', 't6'] : ['t5'];
|
||||||
|
const scripts = games.map(game => {
|
||||||
|
const { script } = createScript({ name: `${game} test`, targetRelPath: `${game}.gsc`,
|
||||||
|
scopeType: 'game', scopeValue: game, content: Buffer.from(`${game} version`) });
|
||||||
|
return { ...script, versionId: getLatestVersion(script.id)!.id };
|
||||||
|
});
|
||||||
|
const betaId = Number(db().prepare(`INSERT INTO betas (script_id, script_version_id, trigger_kind, state)
|
||||||
|
VALUES (?, ?, 'bundle', 'running')`).run(scripts[0].id, scripts[0].versionId).lastInsertRowid);
|
||||||
|
for (const script of scripts) db().prepare('INSERT INTO beta_scripts (beta_id, script_id, script_version_id) VALUES (?, ?, ?)')
|
||||||
|
.run(betaId, script.id, script.versionId);
|
||||||
|
for (const [name, game] of [['first', 't5'], ['second', multiple ? 't6' : 't5']]) {
|
||||||
|
db().prepare('INSERT INTO servers (name, game) VALUES (?, ?)').run(name, game);
|
||||||
|
db().prepare(`INSERT INTO beta_servers (beta_id, server, had_original, prior_version_id, stage)
|
||||||
|
VALUES (?, ?, 1, ?, 'awaiting-empty')`).run(betaId, name, scripts[0].versionId);
|
||||||
|
}
|
||||||
|
const assign = (server: string, scriptId: number) => db().prepare(`INSERT INTO beta_server_scripts
|
||||||
|
(beta_id, server, script_id, had_original) VALUES (?, ?, ?, 0)`).run(betaId, server, scriptId);
|
||||||
|
const memberships = () => db().prepare(`SELECT server, script_id, had_original, prior_version_id
|
||||||
|
FROM beta_server_scripts WHERE beta_id = ? ORDER BY server, script_id`).all(betaId);
|
||||||
|
return { betaId, scripts, assign, memberships, db, closeDb };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('per-server beta membership', () => {
|
||||||
|
it('shows only each game’s held files in fleet and server snapshots', async () => {
|
||||||
|
const x = await seedBeta(true);
|
||||||
|
x.assign('first', x.scripts[0].id);
|
||||||
|
x.assign('second', x.scripts[1].id);
|
||||||
|
const { fleetSnapshot } = await import('../../src/lib/server/fleet/snapshot.js');
|
||||||
|
const expected = [
|
||||||
|
{ name: 'first', held: [{ target: 't5.gsc', betaId: x.betaId }] },
|
||||||
|
{ name: 'second', held: [{ target: 't6.gsc', betaId: x.betaId }] }
|
||||||
|
];
|
||||||
|
expect(fleetSnapshot().servers.map(({ name, held }) => ({ name, held }))).toEqual(expected);
|
||||||
|
expect(fleetSnapshot('second').servers.map(({ name, held }) => ({ name, held }))).toEqual([expected[1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves game-specific membership when the database reopens', async () => {
|
||||||
|
const x = await seedBeta(true);
|
||||||
|
x.assign('first', x.scripts[0].id);
|
||||||
|
x.assign('second', x.scripts[1].id);
|
||||||
|
const expected = x.memberships();
|
||||||
|
x.closeDb();
|
||||||
|
expect(x.memberships()).toEqual(expected);
|
||||||
|
x.closeDb();
|
||||||
|
expect(x.memberships()).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not infer missing bundle membership from the primary script', async () => {
|
||||||
|
const x = await seedBeta(true);
|
||||||
|
x.assign('first', x.scripts[0].id);
|
||||||
|
const expected = x.memberships();
|
||||||
|
x.closeDb();
|
||||||
|
expect(x.memberships()).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still backfills legacy single-script servers while retaining existing snapshots', async () => {
|
||||||
|
const x = await seedBeta(false);
|
||||||
|
x.assign('first', x.scripts[0].id);
|
||||||
|
x.db().prepare('DELETE FROM beta_scripts WHERE beta_id = ?').run(x.betaId);
|
||||||
|
x.closeDb();
|
||||||
|
expect(x.memberships()).toEqual([
|
||||||
|
{ server: 'first', script_id: x.scripts[0].id, had_original: 0, prior_version_id: null },
|
||||||
|
{ server: 'second', script_id: x.scripts[0].id, had_original: 1, prior_version_id: x.scripts[0].versionId }
|
||||||
|
]);
|
||||||
|
expect(x.db().prepare('SELECT script_id, script_version_id FROM beta_scripts WHERE beta_id = ?').all(x.betaId))
|
||||||
|
.toEqual([{ script_id: x.scripts[0].id, script_version_id: x.scripts[0].versionId }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
import { activateFiles } from '../helpers/staged-files.js';
|
||||||
|
|
||||||
|
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn() }));
|
||||||
|
let fixture: Fixture;
|
||||||
|
const t5 = ['t5_zombie_theater_1', 't5_zombie_theater_2'];
|
||||||
|
const t6 = ['t6_zombie_town_1', 't6_zombie_town_2'];
|
||||||
|
const participants = [t5[0], t6[0]];
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture([...t5, ...t6]);
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
});
|
||||||
|
afterEach(async () => { await fixture.cleanup(); });
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const library = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const beta = await import('../../src/lib/server/fleet/beta.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { stagedFileChanges } = await import('../../src/lib/server/fleet/staged-files.js');
|
||||||
|
const { destOfKind, configFileOf } = await import('../../src/lib/server/config.js');
|
||||||
|
const create = (game: string, path = 'shared.gsc') => library.createScript({ name: `${game} ${path}`, targetRelPath: path,
|
||||||
|
scopeType: 'game', scopeValue: game, content: Buffer.from(`${game} tested`) });
|
||||||
|
const open = (files: ReturnType<typeof create>[], servers = participants) => beta.openBeta({
|
||||||
|
scripts: files.map(file => ({ scriptId: file.script.id, scriptVersionId: file.version.id })),
|
||||||
|
servers, waitForHealth: false, waitForMatch: false
|
||||||
|
});
|
||||||
|
const ready = (id: number) => {
|
||||||
|
db().prepare("UPDATE betas SET state = 'ready-promote' WHERE id = ?").run(id);
|
||||||
|
db().prepare("UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?").run(new Date().toISOString(), id);
|
||||||
|
};
|
||||||
|
return { ...library, ...beta, db: db(), stagedFileChanges, destOfKind, configFileOf, create, open, ready };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('multi-game beta bundles', () => {
|
||||||
|
it('stages, revises and rolls back only each server\'s matching files', async () => {
|
||||||
|
const x = await setup();
|
||||||
|
const files = [x.create('t5', 'five.gsc'), x.create('t6', 'six.gsc')];
|
||||||
|
// The other game's filename can already exist independently on a server.
|
||||||
|
const unrelated = join(x.destOfKind(t5[0], 'script'), 'six.gsc');
|
||||||
|
await writeFile(unrelated, 'unrelated original');
|
||||||
|
const { betaId } = await x.open(files);
|
||||||
|
expect(x.stagedFileChanges('beta', String(betaId)).map(row => [row.server, row.rel_path]))
|
||||||
|
.toEqual([[t5[0], 'five.gsc'], [t6[0], 'six.gsc']]);
|
||||||
|
const { heldByBeta, ownerOf } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
expect([...heldByBeta(t5[0]).keys()]).toEqual(['five.gsc']);
|
||||||
|
expect(ownerOf(t5[0], 'six.gsc')).toBeNull();
|
||||||
|
await activateFiles('beta', betaId);
|
||||||
|
await x.reviseBeta(betaId, 1, [{ scriptId: files[1].script.id, content: Buffer.from('t6 revised') }]);
|
||||||
|
expect(x.stagedFileChanges('beta', String(betaId)).map(row => [row.server, row.rel_path]))
|
||||||
|
.toEqual([[t5[0], 'five.gsc'], [t6[0], 'six.gsc']]);
|
||||||
|
await activateFiles('beta', betaId);
|
||||||
|
expect(await readFile(join(x.destOfKind(t6[0], 'script'), 'six.gsc'), 'utf8')).toBe('t6 revised');
|
||||||
|
const rollback = await x.rollbackBeta(betaId);
|
||||||
|
expect(x.stagedFileChanges('campaign', rollback.deploymentId!).map(row => [row.server, row.rel_path]))
|
||||||
|
.toEqual([[t5[0], 'five.gsc'], [t6[0], 'six.gsc']]);
|
||||||
|
await activateFiles('campaign', rollback.deploymentId!);
|
||||||
|
expect(await readFile(unrelated, 'utf8')).toBe('unrelated original');
|
||||||
|
expect(existsSync(join(x.destOfKind(t5[0], 'script'), 'five.gsc'))).toBe(false);
|
||||||
|
expect(existsSync(join(x.destOfKind(t6[0], 'script'), 'six.gsc'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('promotes same-named files to their own game and restores the correct inventory on rollback', async () => {
|
||||||
|
const x = await setup();
|
||||||
|
const { recordScript, getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
|
||||||
|
const files = [x.create('t5'), x.create('t6')];
|
||||||
|
const priors = files.map(file => x.uploadNewVersion(file.script.id, Buffer.from(`${file.script.scope_value} original`)));
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
x.db.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(priors[i].id, files[i].script.id);
|
||||||
|
for (const server of i === 0 ? t5 : t6) {
|
||||||
|
await writeFile(join(x.destOfKind(server, 'script'), 'shared.gsc'), `${files[i].script.scope_value} original`);
|
||||||
|
recordScript(server, 'shared.gsc', priors[i].id, priors[i].sha256, priors[i].size, 'test');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { betaId } = await x.open(files);
|
||||||
|
await activateFiles('beta', betaId); x.ready(betaId);
|
||||||
|
const preview = x.previewBetaPromotion(betaId);
|
||||||
|
expect(preview.files.map(file => file.servers)).toEqual([t5, t6]);
|
||||||
|
expect(preview.blockers).toEqual([]);
|
||||||
|
const promotion = await x.promoteBeta(betaId, preview.token);
|
||||||
|
expect(promotion.awaitingActivation).toBe(2);
|
||||||
|
await activateFiles('campaign', promotion.deploymentId);
|
||||||
|
for (const server of [...t5, ...t6]) {
|
||||||
|
expect(await readFile(join(x.destOfKind(server, 'script'), 'shared.gsc'), 'utf8')).toBe(`${server.slice(0, 2)} tested`);
|
||||||
|
}
|
||||||
|
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
|
||||||
|
const rollback = await rollbackPromoteDeployment(promotion.deploymentId);
|
||||||
|
await activateFiles('campaign', rollback.deploymentId);
|
||||||
|
for (const server of [...t5, ...t6]) {
|
||||||
|
expect(await readFile(join(x.destOfKind(server, 'script'), 'shared.gsc'), 'utf8')).toBe(`${server.slice(0, 2)} original`);
|
||||||
|
expect(getServerScript(server, 'shared.gsc')?.script_version_id).toBe(priors[server.startsWith('t5') ? 0 : 1].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('composes and promotes separate baselines and overlays for both games', async () => {
|
||||||
|
const x = await setup();
|
||||||
|
const files = ['t5', 't6'].flatMap(game => [
|
||||||
|
x.createScript({ name: `${game} baseline`, kind: 'baseline', scopeType: 'game', scopeValue: game,
|
||||||
|
content: Buffer.from(`set baseline_game "${game}"\nset overlay_game "none"\n`) }),
|
||||||
|
x.createScript({ name: `${game} overlay`, kind: 'config', scopeType: 'game', scopeValue: game,
|
||||||
|
content: Buffer.from(`set overlay_game "${game}"\n`) })
|
||||||
|
]);
|
||||||
|
const { betaId } = await x.open(files);
|
||||||
|
await activateFiles('beta', betaId); x.ready(betaId);
|
||||||
|
const promotion = await x.promoteBeta(betaId);
|
||||||
|
await activateFiles('campaign', promotion.deploymentId);
|
||||||
|
for (const server of [...t5, ...t6]) {
|
||||||
|
const content = await readFile(join(x.destOfKind(server, 'config'), x.configFileOf(server)), 'utf8');
|
||||||
|
expect(content).toContain(`set baseline_game "${server.slice(0, 2)}"`);
|
||||||
|
expect(content).toContain(`set overlay_game "${server.slice(0, 2)}"`);
|
||||||
|
expect(content).not.toContain(`"${server.startsWith('t5') ? 't6' : 't5'}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the configuration on script-only participants untouched during rollback', async () => {
|
||||||
|
const x = await setup();
|
||||||
|
const baseline = x.createScript({ name: 'T5 config', kind: 'baseline', scopeType: 'game', scopeValue: 't5', content: Buffer.from('set sv_voice "1"\n') });
|
||||||
|
const config = join(x.destOfKind(t6[0], 'config'), x.configFileOf(t6[0]));
|
||||||
|
await mkdir(dirname(config), { recursive: true }); await writeFile(config, 'unrelated T6 config');
|
||||||
|
const { betaId } = await x.open([baseline, x.create('t6')]);
|
||||||
|
await activateFiles('beta', betaId);
|
||||||
|
const rollback = await x.rollbackBeta(betaId);
|
||||||
|
expect(x.stagedFileChanges('campaign', rollback.deploymentId!, t6[0])).toHaveLength(1);
|
||||||
|
await activateFiles('campaign', rollback.deploymentId!);
|
||||||
|
expect(await readFile(config, 'utf8')).toBe('unrelated T6 config');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies unscoped files to every selected server alongside game-specific files', async () => {
|
||||||
|
const x = await setup();
|
||||||
|
const shared = x.createScript({ name: 'Shared helper', targetRelPath: 'helper.gsc', content: Buffer.from('shared') });
|
||||||
|
const five = x.create('t5'), six = x.create('t6');
|
||||||
|
const { betaId } = await x.open([five, six, shared]);
|
||||||
|
expect(participants.map(server => x.listBetaScripts(betaId, server).map(file => file.script_id)))
|
||||||
|
.toEqual([[shared.script.id, five.script.id], [shared.script.id, six.script.id]]);
|
||||||
|
expect(x.stagedFileChanges('beta', String(betaId)).filter(row => row.rel_path === 'helper.gsc')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects untested files, unused servers and overlapping targets before saving a beta', async () => {
|
||||||
|
const x = await setup();
|
||||||
|
const five = x.create('t5'), six = x.create('t6');
|
||||||
|
await expect(x.open([five, six], [t5[0]])).rejects.toMatchObject({ kind: 'out-of-scope' });
|
||||||
|
await expect(x.open([five], participants)).rejects.toMatchObject({ kind: 'out-of-scope' });
|
||||||
|
const overlap = x.createScript({ name: 'Map overlap', targetRelPath: 'shared.gsc', scopeType: 'map', scopeValue: 'zombie_theater', content: Buffer.from('overlap') });
|
||||||
|
await expect(x.open([five, six, overlap])).rejects.toMatchObject({ kind: 'duplicate-target' });
|
||||||
|
const global = x.createScript({ name: 'Global overlap', targetRelPath: 'shared.gsc', content: Buffer.from('overlap') });
|
||||||
|
await expect(x.open([five, six, global])).rejects.toMatchObject({ kind: 'duplicate-target' });
|
||||||
|
const baselines = [
|
||||||
|
x.createScript({ name: 'Game baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't5', content: Buffer.from('set sv_voice "1"\n') }),
|
||||||
|
x.createScript({ name: 'Map baseline', kind: 'baseline', scopeType: 'map', scopeValue: 'zombie_theater', content: Buffer.from('set sv_voice "0"\n') })
|
||||||
|
];
|
||||||
|
await expect(x.open(baselines, [t5[0]])).rejects.toMatchObject({ kind: 'duplicate-target' });
|
||||||
|
expect(x.db.prepare('SELECT * FROM betas').all()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,6 +68,43 @@ async function tick(seconds = 15, players = 4, healthy = true) {
|
|||||||
const scriptPath = (name: string) => join(fixture.t5, name, 'runtime/plutonium/storage/t5/raw/scripts/sp/test.gsc');
|
const scriptPath = (name: string) => join(fixture.t5, name, 'runtime/plutonium/storage/t5/raw/scripts/sp/test.gsc');
|
||||||
|
|
||||||
describe('durable beta test plans', () => {
|
describe('durable beta test plans', () => {
|
||||||
|
it.each([
|
||||||
|
{ scope: 'quorum', fileScope: 'game', otherServer: 't6_zombie_town_1', otherMap: 'zm_transit' },
|
||||||
|
{ scope: 'game', fileScope: 'map', otherServer: 't5_zombie_cosmodrome_1', otherMap: 'zombie_cosmodrome' }
|
||||||
|
] as const)('requires a passing participant for every file with $scope coverage', async ({ scope, fileScope, otherServer, otherMap }) => {
|
||||||
|
const otherGame = otherServer.slice(0, 2);
|
||||||
|
await mkdir(otherGame === 't6' ? join(fixture.t6, otherServer, 'runtime/plutonium/storage/t6/scripts/zm')
|
||||||
|
: join(fixture.t5, otherServer, 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true });
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
const { createScript, getVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const { openBeta, getBeta, listBetaServers, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
|
const { testView } = await import('../../src/lib/server/fleet/beta-tests.js');
|
||||||
|
const files = [fileScope === 'game' ? 't5' : 'zombie_theater', fileScope === 'game' ? otherGame : otherMap]
|
||||||
|
.map(scopeValue => createScript({ name: `Test ${scopeValue}`, targetRelPath: `${scopeValue}.gsc`,
|
||||||
|
scopeType: fileScope, scopeValue, content: Buffer.from(`${scopeValue} beta`) }));
|
||||||
|
const { betaId } = await openBeta({
|
||||||
|
scripts: files.map(file => ({ scriptId: file.script.id, scriptVersionId: file.version.id })),
|
||||||
|
servers: [...names, otherServer], waitForHealth: true, waitForMatch: false,
|
||||||
|
testPlan: { ...defaultPlan(), scope, quorum: 1, requirements: [requirement('population', 'players')] }
|
||||||
|
});
|
||||||
|
await tick(); await tick();
|
||||||
|
await postTelemetry(gameEvent('game_start')); await tick();
|
||||||
|
expect(listBetaServers(betaId).find(server => server.server === names[0])?.stage).toBe('ready');
|
||||||
|
expect(getBeta(betaId)?.state).toBe('running');
|
||||||
|
expect(testView(betaId)?.outcome).toBe('active');
|
||||||
|
expect(files.map(file => getVersion(file.version.id)?.state)).toEqual(['untested', 'untested']);
|
||||||
|
await expect(promoteBeta(betaId)).rejects.toThrow('Test plan has not passed');
|
||||||
|
|
||||||
|
await postTelemetry(gameEvent('game_start', 25, 4, otherServer, otherMap)); await tick();
|
||||||
|
expect(getBeta(betaId)?.state).toBe('ready-promote');
|
||||||
|
expect(testView(betaId)?.outcome).toBe('passed');
|
||||||
|
expect(files.map(file => getVersion(file.version.id)?.state)).toEqual(['tested', 'tested']);
|
||||||
|
// Coverage still permits replicas to remain unproven once each file
|
||||||
|
// has a passing participant; it does not silently become an all gate.
|
||||||
|
expect(listBetaServers(betaId).find(server => server.server === names[1])?.stage).toBe('observing');
|
||||||
|
});
|
||||||
|
|
||||||
it('requires fresh gameplay after a revision and promotes exactly the replacement version', async () => {
|
it('requires fresh gameplay after a revision and promotes exactly the replacement version', async () => {
|
||||||
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('matches', 'matches')] });
|
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('matches', 'matches')] });
|
||||||
await tick(); await tick();
|
await tick(); await tick();
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
import { prepareNativeHost } from '../helpers/native-host.js';
|
||||||
|
|
||||||
|
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn() }));
|
||||||
|
let fixture: Fixture;
|
||||||
|
const t5 = ['t5_zombie_theater_1', 't5_zombie_theater_2', 't5_zombie_cosmodrome_1'];
|
||||||
|
const t6 = ['t6_zombie_town_1', 't6_zombie_town_2'];
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture([...t5, ...t6]);
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db().prepare(`UPDATE servers SET container_state='running', container_checked_at=?,
|
||||||
|
last_checked_at=?, last_seen_at=?, last_players=0`).run(now, now, now);
|
||||||
|
});
|
||||||
|
afterEach(async () => { await fixture.cleanup(); });
|
||||||
|
|
||||||
|
async function file(scopeType: 'game' | 'map' = 'game', scopeValue = 't5') {
|
||||||
|
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
return createScript({ name: `Test ${scopeValue}`, targetRelPath: `${scopeValue}.gsc`,
|
||||||
|
scopeType, scopeValue, content: Buffer.from('beta bytes') });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('automatic beta server selection', () => {
|
||||||
|
it('selects the requested count independently per game, least occupied first with deterministic ties', async () => {
|
||||||
|
const files = [await file(), await file('game', 't6')];
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db().prepare('UPDATE servers SET last_players=4 WHERE name=?').run(t5[0]);
|
||||||
|
db().prepare('UPDATE servers SET last_players=2 WHERE name=?').run(t6[0]);
|
||||||
|
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
|
||||||
|
expect(selectBetaServers(files.map(f => f.script), { t5: 2, t6: 1 })).toEqual([t5[2], t5[1], t6[1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accounts for occupied telemetry and fresh positive RCON evidence', async () => {
|
||||||
|
const { script } = await file();
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
|
||||||
|
const put = db().prepare('INSERT INTO server_telemetry(server,round,players,received_at,payload) VALUES(?,0,?,?,?)');
|
||||||
|
const report = (server: string, players: number) => ({ event: 'game_start', server, map: 'zombie_theater',
|
||||||
|
receivedAt: new Date().toISOString(), players: Array.from({ length: players }, (_, i) => ({ name: `P${i}`, guid: String(i) })) });
|
||||||
|
put.run(t5[2], 3, new Date().toISOString(), JSON.stringify(report(t5[2], 3)));
|
||||||
|
put.run(t5[0], 0, new Date().toISOString(), JSON.stringify(report(t5[0], 0)));
|
||||||
|
db().prepare('UPDATE servers SET last_players=2 WHERE name=?').run(t5[0]);
|
||||||
|
expect(selectBetaServers([script], { t5: 1 })).toEqual([t5[1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['stale', 'stopped', 'destroyed', 'provisioning', 'beta', 'campaign'])('excludes %s servers', async reason => {
|
||||||
|
const { script, version } = await file();
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const handle = db(), server = t5[2];
|
||||||
|
if (reason === 'stale') handle.prepare("UPDATE servers SET last_seen_at='2000-01-01T00:00:00Z' WHERE name=?").run(server);
|
||||||
|
if (reason === 'stopped') handle.prepare("UPDATE servers SET desired_state='stopped' WHERE name=?").run(server);
|
||||||
|
if (reason === 'destroyed') handle.prepare("UPDATE servers SET destroyed_at='2020-01-01T00:00:00Z' WHERE name=?").run(server);
|
||||||
|
if (reason === 'provisioning') handle.prepare(`INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by)
|
||||||
|
VALUES('busy',?,'destroy','queued','Queued',?,'test')`).run(server, new Date().toISOString());
|
||||||
|
if (reason === 'beta') {
|
||||||
|
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
|
await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers: [server], waitForHealth: false, waitForMatch: false });
|
||||||
|
}
|
||||||
|
if (reason === 'campaign') {
|
||||||
|
handle.prepare("INSERT INTO deployments(id,action,triggered_by,started_at) VALUES('campaign','promote','test',?)").run(new Date().toISOString());
|
||||||
|
handle.prepare("INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('campaign',?,1,'pending')").run(server);
|
||||||
|
}
|
||||||
|
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
|
||||||
|
expect(selectBetaServers([script], { t5: 1 })).toEqual([t5[0]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['disabled', 'draining', 'disconnected', 'deleted'])('excludes instances on a %s host despite fresh game status', async state => {
|
||||||
|
const { script } = await file();
|
||||||
|
const { host } = await prepareNativeHost([t5[2]]);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
if (state === 'disconnected') db().prepare('UPDATE ec_hosts SET last_heartbeat=NULL WHERE id=?').run(host.id);
|
||||||
|
else if (state === 'deleted') db().prepare('UPDATE ec_hosts SET deleted_at=? WHERE id=?').run(new Date().toISOString(), host.id);
|
||||||
|
else db().prepare('UPDATE ec_hosts SET mode=? WHERE id=?').run(state, host.id);
|
||||||
|
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
|
||||||
|
expect(selectBetaServers([script], { t5: 1 })).toEqual([t5[0]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('covers every file scope instead of filling all slots from the emptiest map', async () => {
|
||||||
|
const files = [await file('map', 'zombie_theater'), await file('map', 'zombie_cosmodrome')];
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db().prepare('UPDATE servers SET last_players=3 WHERE name=?').run(t5[2]);
|
||||||
|
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
|
||||||
|
expect(selectBetaServers(files.map(f => f.script), { t5: 2 })).toEqual([t5[0], t5[2]]);
|
||||||
|
expect(() => selectBetaServers(files.map(f => f.script), { t5: 1 })).toThrow('every selected file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reserves game slots when a map can be tested in either game', async () => {
|
||||||
|
const files = [await file('map', 'shared'), await file('map', 'only_t5')];
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db().prepare("UPDATE servers SET map='shared'").run();
|
||||||
|
db().prepare("UPDATE servers SET map='only_t5', last_players=4 WHERE name=?").run(t5[2]);
|
||||||
|
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
|
||||||
|
expect(selectBetaServers(files.map(f => f.script), { t5: 1, t6: 1 })).toEqual([t6[0], t5[2]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails insufficient capacity or untested files before creating a beta', async () => {
|
||||||
|
const files = [await file(), await file('game', 't6')];
|
||||||
|
const { POST } = await import('../../src/routes/api/betas/+server.js');
|
||||||
|
const scripts = files.map(f => ({ scriptId: f.script.id, scriptVersionId: f.version.id }));
|
||||||
|
for (const instancesPerGame of [{ t5: 4, t6: 1 }, { t5: 1 }]) {
|
||||||
|
const response = await POST({ request: new Request('http://test/api/betas', {
|
||||||
|
method: 'POST', body: JSON.stringify({ scripts, instancesPerGame })
|
||||||
|
}) });
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
}
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
expect(db().prepare('SELECT * FROM betas').all()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts automatic API setup and requires every selected instance to pass even with a quorum preset', async () => {
|
||||||
|
const files = [await file(), await file('game', 't6')];
|
||||||
|
const { POST } = await import('../../src/routes/api/betas/+server.js');
|
||||||
|
const { defaultPlan } = await import('../../src/lib/beta-plan.js');
|
||||||
|
const response = await POST({ request: new Request('http://test/api/betas', { method: 'POST', body: JSON.stringify({
|
||||||
|
scripts: files.map(f => ({ scriptId: f.script.id, scriptVersionId: f.version.id })), instancesPerGame: { t5: 2, t6: 1 },
|
||||||
|
testPlan: { ...defaultPlan(), scope: 'quorum', quorum: 1 }
|
||||||
|
}) }) });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.result.servers).toEqual([t5[2], t5[0], t6[0]]);
|
||||||
|
const { storedPlan } = await import('../../src/lib/server/fleet/beta-tests.js');
|
||||||
|
expect(JSON.parse(storedPlan(body.result.betaId)!.plan).scope).toBe('all');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
expect(db().prepare('SELECT * FROM beta_server_scripts').all()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([{}, { t5: 0 }, { t5: -1 }, { t5: 1.5 }, { t5: '2' }, { t5: 1001 }, { t7: 1 }, [], null])('rejects malformed counts %j', async instancesPerGame => {
|
||||||
|
const { script, version } = await file();
|
||||||
|
const { POST } = await import('../../src/routes/api/betas/+server.js');
|
||||||
|
const response = await POST({ request: new Request('http://test/api/betas', { method: 'POST', body: JSON.stringify({
|
||||||
|
scripts: [{ scriptId: script.id, scriptVersionId: version.id }], instancesPerGame
|
||||||
|
}) }) });
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ambiguous simultaneous manual and automatic targeting', async () => {
|
||||||
|
const { script, version } = await file();
|
||||||
|
const { POST } = await import('../../src/routes/api/betas/+server.js');
|
||||||
|
const response = await POST({ request: new Request('http://test/api/betas', { method: 'POST', body: JSON.stringify({
|
||||||
|
scripts: [{ scriptId: script.id, scriptVersionId: version.id }], instancesPerGame: { t5: 1 }, servers: [t5[0]]
|
||||||
|
}) }) });
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { defaultPlan, betaPlanIssues, requirement } from '../../src/lib/beta-plan.js';
|
import { defaultPlan, betaPlanIssues, requirement } from '../../src/lib/beta-plan.js';
|
||||||
import { readBetaDraft, compatibleBetaServers } from '../../src/lib/beta-draft.js';
|
import { readBetaDraft, compatibleBetaServers, betaCandidateServers, betaApplicableGames, betaInstanceCounts, betaScopeMatches } from '../../src/lib/beta-draft.js';
|
||||||
import { betaWaitingCondition, betaEvidenceSummary } from '../../src/lib/beta-evidence.js';
|
import { betaWaitingCondition, betaEvidenceSummary } from '../../src/lib/beta-evidence.js';
|
||||||
|
|
||||||
describe('beta draft and plan recovery', () => {
|
describe('beta draft and plan recovery', () => {
|
||||||
@@ -9,10 +9,24 @@ describe('beta draft and plan recovery', () => {
|
|||||||
const raw = JSON.stringify({ version: 1, savedAt: new Date().toISOString(), step: 2, picks: [{ scriptId: 12, versionId: 45 }], servers: ['town'], testPlan: { ...plan, timeoutHours: null } });
|
const raw = JSON.stringify({ version: 1, savedAt: new Date().toISOString(), step: 2, picks: [{ scriptId: 12, versionId: 45 }], servers: ['town'], testPlan: { ...plan, timeoutHours: null } });
|
||||||
const draft = readBetaDraft(raw)!;
|
const draft = readBetaDraft(raw)!;
|
||||||
expect(draft.picks).toEqual([{ scriptId: 12, versionId: 45 }]);
|
expect(draft.picks).toEqual([{ scriptId: 12, versionId: 45 }]);
|
||||||
|
expect(draft.selectionMode).toBe('manual');
|
||||||
|
expect(draft.servers).toEqual(['town']);
|
||||||
expect(betaPlanIssues(draft.testPlan, 1)).toContainEqual(expect.objectContaining({ field: 'timeoutHours' }));
|
expect(betaPlanIssues(draft.testPlan, 1)).toContainEqual(expect.objectContaining({ field: 'timeoutHours' }));
|
||||||
expect(readBetaDraft('{bad')).toBeNull();
|
expect(readBetaDraft('{bad')).toBeNull();
|
||||||
expect(readBetaDraft(JSON.stringify({ version: 1, picks: [{ scriptId: '12', versionId: 45 }] }))).toBeNull();
|
expect(readBetaDraft(JSON.stringify({ version: 1, picks: [{ scriptId: '12', versionId: 45 }] }))).toBeNull();
|
||||||
});
|
});
|
||||||
|
it('restores automatic selection and incomplete counts without substituting manual selections', () => {
|
||||||
|
const raw = { version: 1, savedAt: '', step: 1, picks: [{ scriptId: 12, versionId: 45 }],
|
||||||
|
servers: ['previous-manual-choice'], selectionMode: 'automatic', instancesPerGame: { t5: 2, t6: null }, testPlan: defaultPlan() };
|
||||||
|
expect(readBetaDraft(JSON.stringify(raw))).toMatchObject({ selectionMode: 'automatic', instancesPerGame: { t5: 2, t6: null } });
|
||||||
|
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { t5: 1.5, t6: -1 } }))?.instancesPerGame).toEqual({ t5: 1.5, t6: -1 });
|
||||||
|
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { t5: '2' } }))).toBeNull();
|
||||||
|
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { unknown: 2 } }))).toBeNull();
|
||||||
|
expect(readBetaDraft(JSON.stringify({ ...raw, selectionMode: 'unknown' }))).toBeNull();
|
||||||
|
});
|
||||||
|
it('defaults a legacy draft with no server choices to automatic selection', () => {
|
||||||
|
expect(readBetaDraft(JSON.stringify({ version: 1, picks: [{ scriptId: 12, versionId: 45 }], servers: [], testPlan: defaultPlan() }))?.selectionMode).toBe('automatic');
|
||||||
|
});
|
||||||
it('keeps compatible explicit selections without selecting newly eligible servers', () => {
|
it('keeps compatible explicit selections without selecting newly eligible servers', () => {
|
||||||
const result = compatibleBetaServers(['town1', 'town2'], ['town2', 'town3']);
|
const result = compatibleBetaServers(['town1', 'town2'], ['town2', 'town3']);
|
||||||
expect([...result.kept]).toEqual(['town2']);
|
expect([...result.kept]).toEqual(['town2']);
|
||||||
@@ -24,6 +38,32 @@ describe('beta draft and plan recovery', () => {
|
|||||||
expect(betaPlanIssues(plan, 2).find(issue => issue.field === 'matches-target')?.requirementId).toBe('matches');
|
expect(betaPlanIssues(plan, 2).find(issue => issue.field === 'matches-target')?.requirementId).toBe('matches');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('multi-game beta setup', () => {
|
||||||
|
const servers = [
|
||||||
|
{ name: 'kino', game: 't5', map: 'zombie_theater' },
|
||||||
|
{ name: 'town', game: 't6', map: 'zm_transit' },
|
||||||
|
{ name: 'origins', game: 't6', map: 'zm_tomb' }
|
||||||
|
];
|
||||||
|
it('offers the union of different file scopes while keeping file-to-server matches separate', () => {
|
||||||
|
const files = [{ scope_type: 'game' as const, scope_value: 't5' }, { scope_type: 'map' as const, scope_value: 'zm_transit' }];
|
||||||
|
expect(betaCandidateServers(files, servers).map(server => server.name)).toEqual(['kino', 'town']);
|
||||||
|
expect(betaApplicableGames(files, servers)).toEqual(['t5', 't6']);
|
||||||
|
expect(betaScopeMatches(files[0], servers[1])).toBe(false);
|
||||||
|
expect(betaScopeMatches(files[1], servers[0])).toBe(false);
|
||||||
|
});
|
||||||
|
it('includes every server for an unscoped file and requires a selected file first', () => {
|
||||||
|
expect(betaCandidateServers([{ scope_type: null, scope_value: null }], servers)).toEqual(servers);
|
||||||
|
expect(betaCandidateServers([], servers)).toEqual([]);
|
||||||
|
expect(betaApplicableGames([], servers)).toEqual([]);
|
||||||
|
});
|
||||||
|
it('keeps explicitly scoped games visible without fleet matches and preserves zero or incomplete counts', () => {
|
||||||
|
const files = [{ scope_type: 'game' as const, scope_value: 't5' }, { scope_type: 'game' as const, scope_value: 't6' }];
|
||||||
|
expect(betaApplicableGames(files, [])).toEqual(['t5', 't6']);
|
||||||
|
expect(betaInstanceCounts(['t5', 't6'], {})).toEqual({ t5: 1, t6: 1 });
|
||||||
|
expect(betaInstanceCounts(['t5', 't6'], { t5: 0, t6: null })).toEqual({ t5: 0, t6: null });
|
||||||
|
expect(betaInstanceCounts(['t6'], { t5: 3, t6: 2 })).toEqual({ t6: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
describe('beta evidence explanations', () => {
|
describe('beta evidence explanations', () => {
|
||||||
const observation = { lastGameplayAt: new Date().toISOString(), lastEvent: 'round_change', players: 2, round: 12, matchStarted: true, gameEnded: false, healthy: true, healthCheckedAt: new Date().toISOString() };
|
const observation = { lastGameplayAt: new Date().toISOString(), lastEvent: 'round_change', players: 2, round: 12, matchStarted: true, gameEnded: false, healthy: true, healthCheckedAt: new Date().toISOString() };
|
||||||
it('distinguishes missing reports, insufficient population, and report-driven time', () => {
|
it('distinguishes missing reports, insufficient population, and report-driven time', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user