Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,7 @@ export class Scheduler {
const integration = await this.integrationForTournament(tournamentId);
const requiresVeto =
integration.capabilities.veto &&
(integration.usesPreMatchPhase?.(tournament) ?? true) &&
tournament.type !== 'shuffle' &&
['bo1', 'bo3', 'bo5'].includes(tournament.format.toLowerCase());

Expand Down
18 changes: 16 additions & 2 deletions api/src/integrations/cs2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ import type { TournamentResponse } from '../../types/tournament.types';
import type { DbTournamentRow } from '../../types/database.types';
import type { MatchReport } from './events/connectionSnapshotService';
import { normalizeConfigPlayers } from '../../utils/playerTransform';
import { cs2TournamentSettings, validateCs2TournamentSettings } from './tournamentSettings';
import {
cs2TournamentSettings,
storedCs2Settings,
validateCs2TournamentSettings,
} from './tournamentSettings';
import { CS2_INSTANCE_SCHEMA, CS2_INSTANCE_SETTINGS } from './settings';
import { CS2_MIGRATIONS } from './migrations';
import type { ServerActionResult, ServerAllocationResult } from './allocation';
Expand Down Expand Up @@ -155,6 +159,11 @@ function toResourceActionResult(result: ServerActionResult): ResourceActionResul
return { ...rest, ...(serverId ? { resourceId: serverId } : {}) };
}

/** The tournament runs the map veto (`settings.cs2.veto` is not false). */
function vetoOn(settings: unknown): boolean {
return storedCs2Settings(settings)?.veto !== false;
}

/** The stored veto (`matches.veto_state`) is finished. */
function vetoCompleted(raw: string | null | undefined): boolean {
if (!raw) return false;
Expand Down Expand Up @@ -253,7 +262,7 @@ export const cs2Integration: GameIntegration = {
* match (veto/routes.ts, veto/simulation.ts).
*/
async isReadyToAllocate(ctx: MatchContext) {
if (!usesVeto(ctx.tournament?.format)) return true;
if (!usesVeto(ctx.tournament?.format) || !vetoOn(ctx.tournament?.settings)) return true;
const { db } = await import('../../config/database');
const row = await db.queryOneAsync<{ veto_state: string | null }>(
'SELECT veto_state FROM matches WHERE slug = ?',
Expand All @@ -262,6 +271,11 @@ export const cs2Integration: GameIntegration = {
return vetoCompleted(row?.veto_state);
},

/** The map veto runs unless the tournament turned it off (`settings.cs2.veto: false`). */
usesPreMatchPhase(tournament) {
return vetoOn(tournament.settings);
},

/**
* The veto opens: the match stays `pending` so the players' veto page and
* the turn timer (veto/timer.ts) pick it up; in simulation mode the
Expand Down
37 changes: 35 additions & 2 deletions api/src/integrations/cs2/matchConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,12 @@ export const generateMatchConfig = async (
const usesLineup =
(team1 && team1Roster !== team1.players) || (team2 && team2Roster !== team2.players);
const fillTeamsWithBots = storedCs2Settings(tournament.settings)?.fillTeamsWithBots === true;
const playersPerTeam =
(usesLineup || fillTeamsWithBots) && tournament.teamSize
// Wingman is 2v2 whatever the team size says (a bot-filled wingman match
// fills to two a side).
const wingman = storedCs2Settings(tournament.settings)?.mapMode === 'wingman';
const playersPerTeam = wingman
? 2
: (usesLineup || fillTeamsWithBots) && tournament.teamSize
? tournament.teamSize
: Math.max(team1Count, team2Count, 1);

Expand Down Expand Up @@ -247,6 +251,14 @@ export const generateMatchConfig = async (
}
}

// 2d) No veto (settings.cs2.veto false): the match plays maps drawn from
// the pool, the same ones on every read (seeded by the slug), and a
// knife round picks the sides.
if (!maplist && storedCs2Settings(tournament.settings)?.veto === false) {
const pool = storedCs2Settings(tournament.settings)?.maps ?? [];
if (pool.length > 0) maplist = drawMaps(pool, numMaps, slug ?? String(existingMatch?.id ?? ''));
}

// 3) Use per_map_sides for map_sides - MatchZy Enhanced expects map_sides array to correspond
// to each map in maplist. If we have veto picks, use them; otherwise use defaults.
let map_sides: Array<'team1_ct' | 'team2_ct' | 'knife'>;
Expand Down Expand Up @@ -772,3 +784,24 @@ export async function serveStandaloneMatchConfig(slug: string): Promise<MatchCon
},
};
}

/**
* `count` maps from `pool` for a match without a veto: a shuffle seeded by
* `seed` (the match slug), so every read of the config draws the same maps.
* A pool smaller than the series repeats maps.
*/
export function drawMaps(pool: readonly string[], count: number, seed: string): string[] {
let h = 2166136261;
for (let i = 0; i < seed.length; i++) h = Math.imul(h ^ seed.charCodeAt(i), 16777619) >>> 0;
const next = () => {
h = Math.imul(h ^ (h >>> 15), 2246822507) >>> 0;
h = Math.imul(h ^ (h >>> 13), 3266489909) >>> 0;
return (h ^= h >>> 16) / 4294967296;
};
const order = [...pool];
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(next() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
return Array.from({ length: count }, (_, i) => order[i % order.length]);
}
7 changes: 7 additions & 0 deletions api/src/integrations/cs2/tournamentSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export interface Cs2TournamentSettings {
* competitive rules, no skins in the server. Off: normal rules, skins on.
*/
esportsMode?: boolean;
/**
* The map veto (default on). Off: no veto, each match plays maps drawn
* from the pool (matchConfig.ts), and the pool needs only one map.
*/
veto?: boolean;
}

/** The map pool size a veto order is checked against when nothing else says (Active Duty). */
Expand Down Expand Up @@ -309,6 +314,7 @@ function applyLayer(
into.fillTeamsWithBots = layer.fillTeamsWithBots;
if (typeof layer.skinRewards === 'boolean') into.skinRewards = layer.skinRewards;
if (typeof layer.esportsMode === 'boolean') into.esportsMode = layer.esportsMode;
if (typeof layer.veto === 'boolean') into.veto = layer.veto;
if (layer.mapPoolId !== undefined) {
const id = Number(layer.mapPoolId);
if (layer.mapPoolId !== null && Number.isInteger(id) && id > 0) into.mapPoolId = id;
Expand Down Expand Up @@ -354,6 +360,7 @@ export const cs2TournamentSettings: ModuleTournamentSettings<Cs2TournamentSettin
: {}),
...(value?.skinRewards !== undefined ? { skinRewards: value.skinRewards } : {}),
...(value?.esportsMode !== undefined ? { esportsMode: value.esportsMode } : {}),
...(value?.veto !== undefined ? { veto: value.veto } : {}),
...(value?.mapPoolId !== undefined ? { mapPoolId: value.mapPoolId } : {}),
...(value?.mapMode ? { mapMode: value.mapMode } : {}),
};
Expand Down
1 change: 1 addition & 0 deletions api/src/integrations/cs2/veto/timer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ async function openVetoMatches(): Promise<DbMatchRow[]> {
WHERE t.status = 'in_progress'
AND t.type <> 'shuffle'
AND LOWER(t.format) IN ('bo1', 'bo3', 'bo5')
AND COALESCE(t.settings::jsonb #>> '{cs2,veto}', 'true') <> 'false'
AND m.team1_id IS NOT NULL
AND m.team2_id IS NOT NULL
AND (m.server_id IS NULL OR m.server_id = '')
Expand Down
31 changes: 24 additions & 7 deletions api/src/integrations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,11 @@ export interface ApplySeriesResultOutcome {
* a `series_result` row in `match_events`.
*/
export interface MatchLifecycleApi {
applySeriesResult(slug: string, result: SeriesResult, meta: ResultMeta): Promise<ApplySeriesResultOutcome>;
applySeriesResult(
slug: string,
result: SeriesResult,
meta: ResultMeta
): Promise<ApplySeriesResultOutcome>;
ingest(events: NormalizedEvent[]): Promise<void>;
}

Expand Down Expand Up @@ -827,12 +831,17 @@ export interface GameIntegration {
* match to `onMatchReady` instead, and the integration readies it itself.
* Omitted means `true`.
*
* CS2: `false` in simulation mode for BO formats, where the automated veto
* runs first. Outside simulation there is no per-match gate: the players'
* veto is gated tournament-wide by `capabilities.veto` in
* `Scheduler.startTournament`.
* CS2: `false` for a BO match whose veto is not done yet (the veto readies
* it). The players' veto is also gated tournament-wide in
* `Scheduler.startTournament` (`capabilities.veto` and usesPreMatchPhase).
*/
isReadyToAllocate?(ctx: MatchContext): Promise<boolean>;
/**
* Whether this tournament runs the pre-match phase at all (CS2: the map
* veto; `settings.cs2.veto: false` turns it off and each match plays maps
* drawn from the pool). Omitted means `capabilities.veto`.
*/
usesPreMatchPhase?(tournament: { settings?: unknown }): boolean;
/**
* A match got both participants and `isReadyToAllocate` returned `false`:
* run the integration's pre-match phase. The integration readies and
Expand Down Expand Up @@ -907,7 +916,10 @@ export interface GameIntegration {
opts: { baseUrl: string; moveResource?: boolean }
): Promise<ResourceActionResult>;
/** Load the match on the resource it is assigned to (the admin "load" action). */
load?(ctx: MatchContext, opts: { baseUrl: string; skipWebhook?: boolean }): Promise<ResourceActionResult>;
load?(
ctx: MatchContext,
opts: { baseUrl: string; skipWebhook?: boolean }
): Promise<ResourceActionResult>;
/**
* Best-effort end of the match on its resource (force-cancel, or a
* tournament restart, reset or delete: see `CancelReason`). Rejects when the
Expand Down Expand Up @@ -1018,7 +1030,12 @@ export interface GameIntegration {
* else's Steam IDs (a draw has no winners), only those with a player
* record, and the map. CS2: virtual skin drops. Must not reject.
*/
onMatchmakingResult?(ctx: { matchSlug: string; winners: string[]; others: string[]; map?: string }): Promise<void>;
onMatchmakingResult?(ctx: {
matchSlug: string;
winners: string[];
others: string[];
map?: string;
}): Promise<void>;
/**
* An elimination tournament this game owns finished: its top three, each
* with the players who played for it (the lineup when the team signed up
Expand Down
1 change: 1 addition & 0 deletions api/src/modules/validateIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const OPTIONAL_METHODS = [
'validateTournamentSettings',
'seed',
'isReadyToAllocate',
'usesPreMatchPhase',
'onMatchReady',
'startPendingPreMatchPhases',
'preMatchTurn',
Expand Down
69 changes: 44 additions & 25 deletions api/src/services/tournamentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ import { log } from '../utils/logger';
import { getBracketGenerator } from './bracketGenerators';
import { validateTeamCount, calculateTotalRounds } from '../utils/tournamentHelpers';
import { moduleTournamentFields, normalizeTournamentSettings } from '../utils/tournamentRow';
import {
moduleSettingsChangeBracket,
withModuleSettings,
} from '../utils/moduleTournamentSettings';
import { moduleSettingsChangeBracket, withModuleSettings } from '../utils/moduleTournamentSettings';
import {
buildMatchConfigFor,
describeMatch,
Expand Down Expand Up @@ -36,13 +33,20 @@ import type {
} from '../types/tournament.types';
import { refreshCurrentTournamentId } from './currentTournament';
import { playerEmailService } from './playerEmailService';
import { bareSlug, isGrandFinalSlug, isLosersBracketSlug, shuffleTeamLike, tournamentSlugPrefix } from '../utils/matchSlug';
import {
bareSlug,
isGrandFinalSlug,
isLosersBracketSlug,
shuffleTeamLike,
tournamentSlugPrefix,
} from '../utils/matchSlug';

/** Players per team from a request (2-10), or null when it names none. */
function validTeamSize(value: unknown): number | null {
if (value === undefined || value === null) return null;
const n = Number(value);
if (!Number.isInteger(n) || n < 1 || n > 10) throw new Error('Players per team must be a whole number from 1 to 10.');
if (!Number.isInteger(n) || n < 1 || n > 10)
throw new Error('Players per team must be a whole number from 1 to 10.');
return n;
}

Expand Down Expand Up @@ -170,9 +174,10 @@ class TournamentService {
if (!teamId) return null;
const known = teamById.get(teamId);
if (known) return known;
const row = await db.queryOneAsync<DbTeamRow>('SELECT id, name, tag FROM teams WHERE id = ?', [
teamId,
]);
const row = await db.queryOneAsync<DbTeamRow>(
'SELECT id, name, tag FROM teams WHERE id = ?',
[teamId]
);
return row ? { id: row.id, name: row.name, tag: row.tag || undefined } : null;
};

Expand Down Expand Up @@ -231,7 +236,8 @@ class TournamentService {

// Shuffle tournaments don't use teams, skip validation. With sign-up open
// the teams sign themselves up later; the count is checked at start.
const signupOpen = (settings as { registrationOpen?: unknown } | undefined)?.registrationOpen === true;
const signupOpen =
(settings as { registrationOpen?: unknown } | undefined)?.registrationOpen === true;
if (type !== 'shuffle' && !signupOpen && !emptyDraft) {
// Validate team count based on tournament type
validateTeamCount(type, teamIds.length);
Expand Down Expand Up @@ -331,7 +337,8 @@ class TournamentService {
// themselves up: then it is checked at start)
const signupOpen =
((settings as { registrationOpen?: unknown } | undefined)?.registrationOpen ??
(existing.settings as { registrationOpen?: unknown } | undefined)?.registrationOpen) === true;
(existing.settings as { registrationOpen?: unknown } | undefined)?.registrationOpen) ===
true;
if ((type || teamIds) && !signupOpen) {
validateTeamCount(type || existing.type, (teamIds || existing.teamIds).length);
}
Expand Down Expand Up @@ -382,7 +389,9 @@ class TournamentService {
if (signupOpen) {
// Teams are still signing up: a count the format cannot draw yet
// (3 of 4) stands, with no bracket until it can (as a sign-up does).
await db.runAsync("DELETE FROM matches WHERE tournament_id = ? AND status = 'pending'", [tournamentId]);
await db.runAsync("DELETE FROM matches WHERE tournament_id = ? AND status = 'pending'", [
tournamentId,
]);
} else {
log.error('Failed to regenerate bracket after update', err);
// Revert changes to teams if bracket generation fails
Expand All @@ -407,7 +416,8 @@ class TournamentService {
throw new Error('Failed to retrieve updated tournament');
}

if (!signupIsOpen(existing.settings) && signupIsOpen(updated.settings)) emailSignupOpen(updated);
if (!signupIsOpen(existing.settings) && signupIsOpen(updated.settings))
emailSignupOpen(updated);
return updated;
}

Expand All @@ -420,13 +430,19 @@ class TournamentService {
* played matches stay; without it (scripts, tests, wipes) everything goes,
* as it always did.
*/
async deleteTournament(tournamentId: number, options: { keepPlayed?: boolean } = {}): Promise<void> {
async deleteTournament(
tournamentId: number,
options: { keepPlayed?: boolean } = {}
): Promise<void> {
// Its matches' live stats go with it; other tournaments' stay.
const slugs = await db.queryAsync<{ slug: string }>('SELECT slug FROM matches WHERE tournament_id = ?', [
const slugs = await db.queryAsync<{ slug: string }>(
'SELECT slug FROM matches WHERE tournament_id = ?',
[tournamentId]
);
// First, clear server_id from all matches to clean up references
await db.runAsync('UPDATE matches SET server_id = NULL WHERE tournament_id = ?', [
tournamentId,
]);
// First, clear server_id from all matches to clean up references
await db.runAsync('UPDATE matches SET server_id = NULL WHERE tournament_id = ?', [tournamentId]);
log.debug('Cleared server references from matches');

// Matches that were played stay, with their stats, ratings and demos
Expand Down Expand Up @@ -488,7 +504,9 @@ class TournamentService {

// Every match belongs to the tournament's game.
const game = tournament.game || DEFAULT_GAME;
const preMatchPhase = integrationForMatch({ game }).capabilities.veto;
const integration = integrationForMatch({ game });
const preMatchPhase =
integration.capabilities.veto && (integration.usesPreMatchPhase?.(tournament) ?? true);

// The generator returns neutral slots. Each slot's game config comes
// from the match's integration, built before the rows are inserted (the
Expand Down Expand Up @@ -595,9 +613,7 @@ class TournamentService {
}

if (Object.keys(updates).length > 0) {
await db.updateAsync('matches', updates as Record<string, unknown>, 'id = ?', [
dbId,
]);
await db.updateAsync('matches', updates as Record<string, unknown>, 'id = ?', [dbId]);
}
}

Expand Down Expand Up @@ -671,9 +687,10 @@ class TournamentService {

// This tournament's live stats go, so its new bracket does not inherit
// stale scores; other tournaments' stay.
const resetSlugs = await db.queryAsync<{ slug: string }>('SELECT slug FROM matches WHERE tournament_id = ?', [
tournamentId,
]);
const resetSlugs = await db.queryAsync<{ slug: string }>(
'SELECT slug FROM matches WHERE tournament_id = ?',
[tournamentId]
);
for (const { slug } of resetSlugs) matchLiveStatsService.clear(slug);

// Delete all matches (this also clears all veto states stored in matches)
Expand Down Expand Up @@ -860,7 +877,9 @@ class TournamentService {
): Promise<void> {
// Link winners‑bracket matches (both single and double elimination)
if (tournamentType === 'single_elimination' || tournamentType === 'double_elimination') {
const winnersMatches = matches.filter((m) => !isLosersBracketSlug(m.slug) && !isGrandFinalSlug(m.slug));
const winnersMatches = matches.filter(
(m) => !isLosersBracketSlug(m.slug) && !isGrandFinalSlug(m.slug)
);
if (winnersMatches.length > 0) {
const maxRound = Math.max(...winnersMatches.map((m) => m.round));

Expand Down
Loading
Loading