diff --git a/api/src/core/scheduler.ts b/api/src/core/scheduler.ts index 0aacea651..818bbade4 100644 --- a/api/src/core/scheduler.ts +++ b/api/src/core/scheduler.ts @@ -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()); diff --git a/api/src/integrations/cs2/index.ts b/api/src/integrations/cs2/index.ts index 53251bf8d..057330cff 100644 --- a/api/src/integrations/cs2/index.ts +++ b/api/src/integrations/cs2/index.ts @@ -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'; @@ -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; @@ -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 = ?', @@ -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 diff --git a/api/src/integrations/cs2/matchConfig.ts b/api/src/integrations/cs2/matchConfig.ts index 3425317bd..5c8e6e54a 100644 --- a/api/src/integrations/cs2/matchConfig.ts +++ b/api/src/integrations/cs2/matchConfig.ts @@ -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); @@ -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'>; @@ -772,3 +784,24 @@ export async function serveStandaloneMatchConfig(slug: string): Promise>> 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]); +} diff --git a/api/src/integrations/cs2/tournamentSettings.ts b/api/src/integrations/cs2/tournamentSettings.ts index f126e33b2..2d2cc1986 100644 --- a/api/src/integrations/cs2/tournamentSettings.ts +++ b/api/src/integrations/cs2/tournamentSettings.ts @@ -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). */ @@ -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; @@ -354,6 +360,7 @@ export const cs2TournamentSettings: ModuleTournamentSettings { 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 = '') diff --git a/api/src/integrations/types.ts b/api/src/integrations/types.ts index 9af2fe0bd..0d21eb831 100644 --- a/api/src/integrations/types.ts +++ b/api/src/integrations/types.ts @@ -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; + applySeriesResult( + slug: string, + result: SeriesResult, + meta: ResultMeta + ): Promise; ingest(events: NormalizedEvent[]): Promise; } @@ -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; + /** + * 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 @@ -907,7 +916,10 @@ export interface GameIntegration { opts: { baseUrl: string; moveResource?: boolean } ): Promise; /** Load the match on the resource it is assigned to (the admin "load" action). */ - load?(ctx: MatchContext, opts: { baseUrl: string; skipWebhook?: boolean }): Promise; + load?( + ctx: MatchContext, + opts: { baseUrl: string; skipWebhook?: boolean } + ): Promise; /** * Best-effort end of the match on its resource (force-cancel, or a * tournament restart, reset or delete: see `CancelReason`). Rejects when the @@ -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; + onMatchmakingResult?(ctx: { + matchSlug: string; + winners: string[]; + others: string[]; + map?: string; + }): Promise; /** * 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 diff --git a/api/src/modules/validateIntegration.ts b/api/src/modules/validateIntegration.ts index 18799c514..1b7439d86 100644 --- a/api/src/modules/validateIntegration.ts +++ b/api/src/modules/validateIntegration.ts @@ -27,6 +27,7 @@ const OPTIONAL_METHODS = [ 'validateTournamentSettings', 'seed', 'isReadyToAllocate', + 'usesPreMatchPhase', 'onMatchReady', 'startPendingPreMatchPhases', 'preMatchTurn', diff --git a/api/src/services/tournamentService.ts b/api/src/services/tournamentService.ts index 8f28c0af5..bc9e49eca 100644 --- a/api/src/services/tournamentService.ts +++ b/api/src/services/tournamentService.ts @@ -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, @@ -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; } @@ -170,9 +174,10 @@ class TournamentService { if (!teamId) return null; const known = teamById.get(teamId); if (known) return known; - const row = await db.queryOneAsync('SELECT id, name, tag FROM teams WHERE id = ?', [ - teamId, - ]); + const row = await db.queryOneAsync( + 'SELECT id, name, tag FROM teams WHERE id = ?', + [teamId] + ); return row ? { id: row.id, name: row.name, tag: row.tag || undefined } : null; }; @@ -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); @@ -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); } @@ -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 @@ -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; } @@ -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 { + async deleteTournament( + tournamentId: number, + options: { keepPlayed?: boolean } = {} + ): Promise { // 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 @@ -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 @@ -595,9 +613,7 @@ class TournamentService { } if (Object.keys(updates).length > 0) { - await db.updateAsync('matches', updates as Record, 'id = ?', [ - dbId, - ]); + await db.updateAsync('matches', updates as Record, 'id = ?', [dbId]); } } @@ -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) @@ -860,7 +877,9 @@ class TournamentService { ): Promise { // 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)); diff --git a/client/src/components/tournament/setup/TournamentSetup.tsx b/client/src/components/tournament/setup/TournamentSetup.tsx index a94ec57ff..e344615a5 100644 --- a/client/src/components/tournament/setup/TournamentSetup.tsx +++ b/client/src/components/tournament/setup/TournamentSetup.tsx @@ -1,14 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { - Alert, - Box, - Button, - Menu, - MenuItem, - TextField, - Typography, -} from '@mui/material'; +import { Alert, Box, Button, Menu, MenuItem, TextField, Typography } from '@mui/material'; import { ArrowLeftIcon, ArrowRightIcon, FileTextIcon } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; import { api } from '../../../utils/api'; @@ -245,8 +237,10 @@ export function TournamentSetup(props: TournamentSetupProps) { const removed = current.filter((id) => !next.includes(id)); setChangingTeams(true); try { - for (const teamId of added) await api.post(`/api/tournaments/${tournament.id}/teams`, { teamId }); - for (const teamId of removed) await api.delete(`/api/tournaments/${tournament.id}/teams/${encodeURIComponent(teamId)}`); + for (const teamId of added) + await api.post(`/api/tournaments/${tournament.id}/teams`, { teamId }); + for (const teamId of removed) + await api.delete(`/api/tournaments/${tournament.id}/teams/${encodeURIComponent(teamId)}`); } catch (error) { toastError((error as Error).message); } finally { @@ -255,7 +249,6 @@ export function TournamentSetup(props: TournamentSetupProps) { } }; - // ---- Templates ("Start from a template") --------------------------------- const [templates, setTemplates] = useState([]); const [templateMenu, setTemplateMenu] = useState(null); @@ -280,7 +273,9 @@ export function TournamentSetup(props: TournamentSetupProps) { format: form.format, teamCount: form.selectedTeams.length, teamSize: form.shuffleSettings.teamSize, - signupOpen: tournament ? tournament.settings?.registrationOpen === true : form.signup.registrationOpen, + signupOpen: tournament + ? tournament.settings?.registrationOpen === true + : form.signup.registrationOpen, // Only a step the game has: a game without match rules has nothing to check. moduleError: (step: 'rules' | 'content') => (step === 'rules' ? RulesStep : ContentStep) @@ -490,7 +485,10 @@ export function TournamentSetup(props: TournamentSetupProps) { disabled={locked} fullWidth required - placeholder={t('tournament.namePlaceholder', 'Auto Tournament 2025 Spring Tournament')} + placeholder={t( + 'tournament.namePlaceholder', + 'Auto Tournament 2025 Spring Tournament' + )} slotProps={{ htmlInput: { 'data-testid': 'tournament-name-input' } }} /> void saveSignup(), dirty: savedSignup.dirty, saving: savingSignup } + ? { + onSave: () => void saveSignup(), + dirty: savedSignup.dirty, + saving: savingSignup, + } : {})} /> {tournament && ( @@ -665,28 +667,30 @@ export function TournamentSetup(props: TournamentSetupProps) { /> )} - {signup.registrationOpen ? t('tournament.setup.signup.addYourself') : t('tournament.setup.signup.pickTeams')} + {signup.registrationOpen + ? t('tournament.setup.signup.addYourself') + : t('tournament.setup.signup.pickTeams')} - = Math.ceil(selectedCount / 2) - } - loadingServers={needsServers && loadingServers} - canEdit={canEdit} - saving={saving || changingTeams} - onTeamsChange={(next) => void changeTeams(next)} - onCreateTeam={() => setTeamModalOpen(true)} - onImportTeams={() => setTeamImportModalOpen(true)} - onAddServer={AddResourceDialog ? () => setServerModalOpen(true) : undefined} - onBatchAddServers={BatchResourceDialog ? () => setBatchServerModalOpen(true) : undefined} - /> + = Math.ceil(selectedCount / 2)} + loadingServers={needsServers && loadingServers} + canEdit={canEdit} + saving={saving || changingTeams} + onTeamsChange={(next) => void changeTeams(next)} + onCreateTeam={() => setTeamModalOpen(true)} + onImportTeams={() => setTeamImportModalOpen(true)} + onAddServer={AddResourceDialog ? () => setServerModalOpen(true) : undefined} + onBatchAddServers={ + BatchResourceDialog ? () => setBatchServerModalOpen(true) : undefined + } + /> ); @@ -698,21 +702,22 @@ export function TournamentSetup(props: TournamentSetupProps) { <> {ContentStep && } {integration.capabilities.veto && ( - - - {isShuffle - ? t('tournament.setup.maps.vetoShuffle') - : customVeto - ? t('tournament.setup.maps.vetoCustom', { format: seriesLabel }) - : requiresVeto(form.type, form.format) - ? t('tournament.setup.maps.vetoStandard', { format: seriesLabel }) - : t('tournament.setup.maps.vetoNone')} - - + + + {isShuffle + ? t('tournament.setup.maps.vetoShuffle') + : customVeto + ? t('tournament.setup.maps.vetoCustom', { format: seriesLabel }) + : requiresVeto(form.type, form.format) && + (form.gameSettings?.cs2 as { veto?: boolean } | undefined)?.veto !== false + ? t('tournament.setup.maps.vetoStandard', { format: seriesLabel }) + : t('tournament.setup.maps.vetoNone')} + + )} ); @@ -870,7 +875,6 @@ export function TournamentSetup(props: TournamentSetupProps) { [MEDIUM]: { gridTemplateColumns: 'minmax(0, 1fr)', gap: 4 }, }} > - { + const maps = availableMaps.filter((m) => fitsMapMode(m, m.id, 'wingman')).map((m) => m.id); + onChange( + cs2Patch({ + ...withMaps(cs2, maps, type, null), + mapMode: 'wingman', + veto: false, + maxRounds: 16, + }) + ); + }; + + const vetoFormat = type !== 'shuffle' && ['bo1', 'bo3', 'bo5'].includes(format); + return ( <> + {vetoFormat && ( + + onChange(cs2Patch({ ...cs2, veto: checked }))} + slotProps={{ + input: { + 'data-testid': 'tournament-map-veto', + } as InputHTMLAttributes, + }} + /> + } + /> + + + {cs2.veto === false ? t('tournament.mapVeto.offHelp') : t('tournament.mapVeto.onHelp')} + + + )} setSaveOpen(true)} mapMode={cs2.mapMode} + veto={cs2.veto !== false} onMapModeChange={(mode) => { // Maps of another type leave; so does the pool, when it had any. const kept = cs2.maps.filter((id) => diff --git a/client/src/integrations/cs2/setup/MapPoolStep.tsx b/client/src/integrations/cs2/setup/MapPoolStep.tsx index f98c60289..5a4104902 100644 --- a/client/src/integrations/cs2/setup/MapPoolStep.tsx +++ b/client/src/integrations/cs2/setup/MapPoolStep.tsx @@ -54,6 +54,8 @@ export interface MapPoolStepProps { */ mapMode?: MapGameMode; onMapModeChange?: (mode: MapGameMode | undefined) => void; + /** The map veto is on (default); off: each match draws its maps from the pool. */ + veto?: boolean; } export function MapPoolStep({ @@ -74,6 +76,7 @@ export function MapPoolStep({ enableOrdering = true, mapMode, onMapModeChange, + veto = true, }: MapPoolStepProps) { const { t } = useModuleTranslation('cs2'); @@ -121,8 +124,8 @@ export function MapPoolStep({ const isShuffle = type === 'shuffle'; // Use verification rules system - const mapValidation = validateMapCount(maps, type || '', format); - const shouldShowVetoError = requiresVeto(type || '', format) && !mapValidation.valid; + const mapValidation = validateMapCount(maps, type || '', format, veto); + const shouldShowVetoError = requiresVeto(type || '', format, veto) && !mapValidation.valid; return ( diff --git a/client/src/integrations/cs2/setup/cs2TournamentSettings.ts b/client/src/integrations/cs2/setup/cs2TournamentSettings.ts index e3e36cf37..3849b9a69 100644 --- a/client/src/integrations/cs2/setup/cs2TournamentSettings.ts +++ b/client/src/integrations/cs2/setup/cs2TournamentSettings.ts @@ -52,6 +52,8 @@ export interface Cs2TournamentSettings { fillTeamsWithBots?: boolean; /** Esports mode: Valve's rules, no skins in the server. */ esportsMode?: boolean; + /** The map veto (default on); off: each match draws its maps from the pool. */ + veto?: boolean; } export const CS2_DEFAULTS: Cs2TournamentSettings = { @@ -98,6 +100,7 @@ function applyFields(into: Cs2TournamentSettings, source: Record { if (next.mapPoolId !== undefined) stored.mapPoolId = next.mapPoolId; stored.fillTeamsWithBots = next.fillTeamsWithBots === true; stored.esportsMode = next.esportsMode === true; + stored.veto = next.veto !== false; if (next.skinRewards !== undefined) stored.skinRewards = next.skinRewards; // null clears a type set before (the API keeps a field a patch leaves out). stored.mapMode = next.mapMode ?? null; @@ -218,7 +222,7 @@ export const cs2TournamentSetup: TournamentSetupModel = { if (step === 'rules') { return cs2.maxRounds < 1 || cs2.maxRounds > 30 ? t('tournament.toasts.maxRoundsRange') : null; } - const validation = validateMapCount(cs2.maps, ctx.type, ctx.format); + const validation = validateMapCount(cs2.maps, ctx.type, ctx.format, cs2.veto !== false); return validation.valid ? null : (validation.message ?? t('tournament.toasts.invalidMapSelection')); @@ -251,10 +255,10 @@ export const cs2TournamentSetup: TournamentSetupModel = { checklist: [ { key: 'maps', - label: requiresVeto(ctx.type, ctx.format) + label: requiresVeto(ctx.type, ctx.format, cs2.veto !== false) ? t('tournament.setup.checklist.mapsVeto') : t('tournament.setup.checklist.maps'), - met: validateMapCount(cs2.maps, ctx.type, ctx.format).valid, + met: validateMapCount(cs2.maps, ctx.type, ctx.format, cs2.veto !== false).valid, }, ], review: [ diff --git a/client/src/integrations/cs2/setup/mapRules.ts b/client/src/integrations/cs2/setup/mapRules.ts index 207cd1257..ae4ac113d 100644 --- a/client/src/integrations/cs2/setup/mapRules.ts +++ b/client/src/integrations/cs2/setup/mapRules.ts @@ -15,7 +15,7 @@ interface MapRule { message: string; } -function mapRule(type: string, format?: string): MapRule { +function mapRule(type: string, format?: string, veto = true): MapRule { if (type === 'shuffle') { return { minMaps: 1, @@ -23,6 +23,14 @@ function mapRule(type: string, format?: string): MapRule { message: 'Select at least one map. Each map represents one round.', }; } + if (format && ['bo1', 'bo3', 'bo5'].includes(format) && !veto) { + // No veto: each match draws its maps from the pool. + return { + minMaps: 1, + requiresVeto: false, + message: 'Select at least one map. Each match draws its maps from the pool.', + }; + } if (format && ['bo1', 'bo3', 'bo5'].includes(format)) { return { exactMaps: 7, requiresVeto: true, message: 'Map veto requires exactly 7 maps.' }; } @@ -33,9 +41,10 @@ function mapRule(type: string, format?: string): MapRule { export function validateMapCount( maps: string[], type: string, - format?: string + format?: string, + veto = true ): { valid: boolean; message?: string } { - const rule = mapRule(type, format); + const rule = mapRule(type, format, veto); const count = maps.length; if (rule.exactMaps !== undefined && count !== rule.exactMaps) { @@ -48,6 +57,6 @@ export function validateMapCount( } /** Whether the type and format run a map veto (bo1/bo3/bo5, not shuffle). */ -export function requiresVeto(type: string, format?: string): boolean { - return mapRule(type, format).requiresVeto; +export function requiresVeto(type: string, format?: string, veto = true): boolean { + return mapRule(type, format, veto).requiresVeto; } diff --git a/client/src/integrations/cs2/veto/VetoInterface.tsx b/client/src/integrations/cs2/veto/VetoInterface.tsx index ada118e78..ebe459797 100644 --- a/client/src/integrations/cs2/veto/VetoInterface.tsx +++ b/client/src/integrations/cs2/veto/VetoInterface.tsx @@ -20,7 +20,14 @@ import { getMapData, getMapDisplayName } from '../maps/mapData'; import { getVetoOrder } from './vetoOrders'; import type { MapSide, VetoMapInfo, VetoState, VetoStateResponse } from '../cs2.types'; import { FadeInImage } from '../common/FadeInImage'; -import { onSocketReconnect, useSocket, tokens, mono, withAlpha, useModuleTranslation } from '../../../module-sdk'; +import { + onSocketReconnect, + useSocket, + tokens, + mono, + withAlpha, + useModuleTranslation, +} from '../../../module-sdk'; import type { PreMatchViewProps as VetoInterfaceProps } from '../../types'; const { color, radius } = tokens; @@ -38,17 +45,24 @@ export const VetoInterface: React.FC = ({ const translateVetoError = useCallback( (backendError: string | undefined): string | undefined => { if (!backendError) return undefined; - if (backendError.includes('not your turn') || backendError.includes('Waiting for the other team')) + if ( + backendError.includes('not your turn') || + backendError.includes('Waiting for the other team') + ) return t('vetoInterface.errors.notYourTurn'); - if (backendError.includes('already completed')) return t('vetoInterface.errors.vetoAlreadyCompleted'); - if (backendError.includes('Invalid map')) return t('vetoInterface.errors.invalidMapSelection'); - if (backendError.includes('Invalid side')) return t('vetoInterface.errors.invalidSideSelection'); + if (backendError.includes('already completed')) + return t('vetoInterface.errors.vetoAlreadyCompleted'); + if (backendError.includes('Invalid map')) + return t('vetoInterface.errors.invalidMapSelection'); + if (backendError.includes('Invalid side')) + return t('vetoInterface.errors.invalidSideSelection'); if (backendError.includes('No map to pick')) return t('vetoInterface.errors.noMapToPickSide'); if (backendError.includes('Match not found')) return t('vetoInterface.errors.matchNotFound'); - if (backendError.includes('participating teams')) return t('vetoInterface.errors.unauthorized'); + if (backendError.includes('participating teams')) + return t('vetoInterface.errors.unauthorized'); return backendError; }, - [t], + [t] ); const [vetoState, setVetoState] = useState(null); @@ -167,13 +181,12 @@ export const VetoInterface: React.FC = ({ const pickedMaps = Array.isArray(vetoState.pickedMaps) ? vetoState.pickedMaps : []; // Use allMaps if available (preserves original order), otherwise reconstruct - const originalMapOrder = Array.isArray(vetoState.allMaps) && vetoState.allMaps.length > 0 - ? [...vetoState.allMaps] // Create a copy to ensure immutability - : [ - ...availableMaps, - ...bannedMaps, - ...pickedMaps.map((p) => p.mapName), - ].filter((mapId, index, self) => self.indexOf(mapId) === index); // Fallback: remove duplicates + const originalMapOrder = + Array.isArray(vetoState.allMaps) && vetoState.allMaps.length > 0 + ? [...vetoState.allMaps] // Create a copy to ensure immutability + : [...availableMaps, ...bannedMaps, ...pickedMaps.map((p) => p.mapName)].filter( + (mapId, index, self) => self.indexOf(mapId) === index + ); // Fallback: remove duplicates return originalMapOrder.map((mapId) => { const mapData = allMaps.get(mapId); @@ -258,7 +271,9 @@ export const VetoInterface: React.FC = ({ setActionError(''); } else { console.error('Side pick failed:', data.error); - setActionError(translateVetoError(data.error) || t('vetoInterface.errors.failedToPickSide')); + setActionError( + translateVetoError(data.error) || t('vetoInterface.errors.failedToPickSide') + ); } } catch (err) { console.error('Error picking side:', err); @@ -313,9 +328,7 @@ export const VetoInterface: React.FC = ({ {t('vetoInterface.vetoCompleted')} - - {t('vetoInterface.vetoCompletedSubtitle')} - + {t('vetoInterface.vetoCompletedSubtitle')} @@ -332,14 +345,16 @@ export const VetoInterface: React.FC = ({ const displaySide = isViewingTeam1 ? pick.sideTeam1 : isViewingTeam2 - ? pick.sideTeam2 - : pick.sideTeam1; // Fallback to team1 if unknown + ? pick.sideTeam2 + : pick.sideTeam1; // Fallback to team1 if unknown return ( = ({ // The action's own colour and icon: ban red (danger), pick green, side choice blue. Your turn // outlines the card, banner and map grid in it; waiting stays neutral. - const actionColor = currentAction === 'ban' ? color.ban : currentAction === 'pick' ? color.pick : color.info; - const ActionIcon = currentAction === 'ban' ? ProhibitIcon : currentAction === 'pick' ? CheckCircleIcon : ArrowsLeftRightIcon; + const actionColor = + currentAction === 'ban' ? color.ban : currentAction === 'pick' ? color.pick : color.info; + const ActionIcon = + currentAction === 'ban' + ? ProhibitIcon + : currentAction === 'pick' + ? CheckCircleIcon + : ArrowsLeftRightIcon; return ( @@ -400,7 +421,14 @@ export const VetoInterface: React.FC = ({ {/* Match Header (left out where the page already shows who plays) */} {!hideMatchHeader && ( - + = ({ variant="h4" fontWeight={700} component={ - vetoState.team1Id && - vetoState.team1Id !== 'team1' && - vetoState.team1Id !== 'team2' + vetoState.team1Id && vetoState.team1Id !== 'team1' && vetoState.team1Id !== 'team2' ? RouterLink : 'span' } to={ - vetoState.team1Id && - vetoState.team1Id !== 'team1' && - vetoState.team1Id !== 'team2' + vetoState.team1Id && vetoState.team1Id !== 'team1' && vetoState.team1Id !== 'team2' ? `/team/${vetoState.team1Id}` : undefined } @@ -448,16 +472,12 @@ export const VetoInterface: React.FC = ({ variant="h4" fontWeight={700} component={ - vetoState.team2Id && - vetoState.team2Id !== 'team1' && - vetoState.team2Id !== 'team2' + vetoState.team2Id && vetoState.team2Id !== 'team1' && vetoState.team2Id !== 'team2' ? RouterLink : 'span' } to={ - vetoState.team2Id && - vetoState.team2Id !== 'team1' && - vetoState.team2Id !== 'team2' + vetoState.team2Id && vetoState.team2Id !== 'team1' && vetoState.team2Id !== 'team2' ? `/team/${vetoState.team2Id}` : undefined } @@ -484,6 +504,18 @@ export const VetoInterface: React.FC = ({ )} + {/* The turn's time, for everyone (both teams and spectators), every + step including the side pick: a bar that drains as the clock runs. */} + {vetoState.turnDeadline && ( + + )} + {/* Progress Header */} = ({ {currentAction === 'ban' ? t('vetoInterface.question.ban') : currentAction === 'pick' - ? t('vetoInterface.question.pick') - : t('vetoInterface.question.side')} + ? t('vetoInterface.question.pick') + : t('vetoInterface.question.side')} {currentAction !== 'side_pick' && ( @@ -565,13 +597,18 @@ export const VetoInterface: React.FC = ({ {currentAction === 'ban' ? t('vetoInterface.waitingToBan', { team: currentTeamName }) : currentAction === 'pick' - ? t('vetoInterface.waitingToPick', { team: currentTeamName }) - : t('vetoInterface.waitingToChooseSide', { team: currentTeamName })} + ? t('vetoInterface.waitingToPick', { team: currentTeamName }) + : t('vetoInterface.waitingToChooseSide', { team: currentTeamName })} {t('vetoInterface.pageUpdatesAutomatically')} @@ -587,7 +624,15 @@ export const VetoInterface: React.FC = ({ (team === 'team1' ? team1Name : team2Name)} mapName={(m) => allMaps.get(m)?.displayName || getMapDisplayName(m)} /> @@ -598,7 +643,9 @@ export const VetoInterface: React.FC = ({ {currentAction === 'side_pick' && (() => { const pickedMaps = Array.isArray(vetoState.pickedMaps) ? vetoState.pickedMaps : []; - const availableMaps = Array.isArray(vetoState.availableMaps) ? vetoState.availableMaps : []; + const availableMaps = Array.isArray(vetoState.availableMaps) + ? vetoState.availableMaps + : []; // BO1 and BO3 decider: the side pick is for the *remaining* map, // which is not in pickedMaps yet (the server adds it when side is submitted). @@ -665,9 +712,7 @@ export const VetoInterface: React.FC = ({ color="text.primary" sx={{ textShadow: `0 2px 12px ${color.shadow}` }} > - {mapData?.displayName || - fallbackData?.displayName || - sidePickMapName} + {mapData?.displayName || fallbackData?.displayName || sidePickMapName} = ({ } > - {mapsToShow - // Decided maps leave the grid; they are listed in one line below it. - .filter( - (map) => - !vetoState.bannedMaps.includes(map.name) && - !vetoState.pickedMaps.find((p) => p.mapName === map.name) - ) - .map((map) => { - const mapState = vetoState.bannedMaps.includes(map.name) - ? 'banned' - : vetoState.pickedMaps.find((p) => p.mapName === map.name) - ? 'picked' - : 'available'; - - const pickedMap = vetoState.pickedMaps.find((p) => p.mapName === map.name); - // Show the side for the team viewing (team1 sees sideTeam1, team2 sees sideTeam2) - const displaySide = pickedMap - ? isViewingTeam1 - ? pickedMap.sideTeam1 - : isViewingTeam2 - ? pickedMap.sideTeam2 - : pickedMap.sideTeam1 // Fallback to team1 if unknown - : undefined; - - return ( - - handleMapAction(map.name)} - disabled={mapState !== 'available' || !isMyTurn} - isCurrentTurn={isMyTurn && mapState === 'available'} - currentAction={currentAction} - isDecider={mapState === 'picked' && isDeciderMap(map.name)} - /> - - ); - })} + {mapsToShow + // Decided maps leave the grid; they are listed in one line below it. + .filter( + (map) => + !vetoState.bannedMaps.includes(map.name) && + !vetoState.pickedMaps.find((p) => p.mapName === map.name) + ) + .map((map) => { + const mapState = vetoState.bannedMaps.includes(map.name) + ? 'banned' + : vetoState.pickedMaps.find((p) => p.mapName === map.name) + ? 'picked' + : 'available'; + + const pickedMap = vetoState.pickedMaps.find((p) => p.mapName === map.name); + // Show the side for the team viewing (team1 sees sideTeam1, team2 sees sideTeam2) + const displaySide = pickedMap + ? isViewingTeam1 + ? pickedMap.sideTeam1 + : isViewingTeam2 + ? pickedMap.sideTeam2 + : pickedMap.sideTeam1 // Fallback to team1 if unknown + : undefined; + + return ( + + handleMapAction(map.name)} + disabled={mapState !== 'available' || !isMyTurn} + isCurrentTurn={isMyTurn && mapState === 'available'} + currentAction={currentAction} + isDecider={mapState === 'picked' && isDeciderMap(map.name)} + /> + + ); + })} )} @@ -821,7 +866,15 @@ export const VetoInterface: React.FC = ({ {(vetoState.pickedMaps.length > 0 || vetoState.bannedMaps.length > 0) && ( {vetoState.pickedMaps.map((pick) => ( @@ -839,16 +892,78 @@ export const VetoInterface: React.FC = ({ {t('vetoInterface.decided.out')} {' '} - {vetoState.bannedMaps.map((m) => allMaps.get(m)?.displayName || getMapDisplayName(m)).join(', ')} + {vetoState.bannedMaps + .map((m) => allMaps.get(m)?.displayName || getMapDisplayName(m)) + .join(', ')} )} )} - ); }; +/** The turn's time as a bar that drains to empty, with the seconds left; red in the last ten. */ +function TurnBar({ + deadline, + seconds, + mine, + tone, + label, +}: { + deadline: string; + seconds?: number; + mine: boolean; + tone: string; + label: string; +}) { + const end = Date.parse(deadline); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 250); + return () => window.clearInterval(id); + }, []); + const leftMs = Math.max(0, end - now); + const total = seconds && seconds > 0 ? seconds * 1000 : Math.max(leftMs, 1); + const pct = Math.max(0, Math.min(100, (leftMs / total) * 100)); + const left = Math.ceil(leftMs / 1000); + const urgent = left <= 10; + const fill = urgent ? color.ban : mine ? tone : color.ink2; + return ( + + + {label} + {`${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`} + + + + + + ); +} /** The turn's countdown: "0:24", red in the last ten seconds, "Time's up" at zero. */ function TurnClock({ deadline, mine, tone }: { deadline: string; mine: boolean; tone: string }) { @@ -861,7 +976,10 @@ function TurnClock({ deadline, mine, tone }: { deadline: string; mine: boolean; }, []); const left = Math.max(0, Math.ceil((end - now) / 1000)); const urgent = left <= 10; - const text = left > 0 ? `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}` : t('vetoInterface.timeUp'); + const text = + left > 0 + ? `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}` + : t('vetoInterface.timeUp'); return ( i + 1); - const who = (team: string) => (mySide ? (team === mySide ? t('vetoInterface.strip.you') : t('vetoInterface.strip.them')) : teamName(team)); - const tone = (action: string) => (action === 'ban' ? color.ban : action === 'pick' ? color.pick : color.info); + const who = (team: string) => + mySide + ? team === mySide + ? t('vetoInterface.strip.you') + : t('vetoInterface.strip.them') + : teamName(team); + const tone = (action: string) => + action === 'ban' ? color.ban : action === 'pick' ? color.pick : color.info; return ( {steps.map((step) => { const done = actions.find((a) => a.step === step); const current = !done && step === vetoState.currentStep && vetoState.status !== 'completed'; const planned = order[step - 1]; - const action = done?.action ?? (current ? vetoState.currentAction : planned?.action) ?? 'ban'; + const action = + done?.action ?? (current ? vetoState.currentAction : planned?.action) ?? 'ban'; const team = done?.team ?? (current ? vetoState.currentTurn : planned?.team); return ( - + {step} - {t(`vetoInterface.actionLabels.${action}`, { defaultValue: String(action).toUpperCase() })} + {t(`vetoInterface.actionLabels.${action}`, { + defaultValue: String(action).toUpperCase(), + })} - + {done ? done.action === 'side_pick' ? t('vetoInterface.startingSide', { side: done.side }) diff --git a/tests/api/cs2-no-veto.spec.ts b/tests/api/cs2-no-veto.spec.ts new file mode 100644 index 000000000..9bd7bb064 --- /dev/null +++ b/tests/api/cs2-no-veto.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; +import { drawMaps } from '../../api/src/integrations/cs2/matchConfig'; +import { cs2Integration } from '../../api/src/integrations/cs2'; + +/** + * A tournament without a veto (`settings.cs2.veto: false`): no pre-match + * phase, and each match plays maps drawn from the pool, the same on every + * read of its config. + * + * @tag api + */ +const TAGS = { tag: ['@api'] }; + +test( + 'the map draw is the same for a match every time, and fills a series from a small pool', + TAGS, + () => { + const pool = ['de_inferno', 'de_nuke', 'de_overpass', 'de_vertigo', 'de_palacio', 'de_rooftop']; + expect(drawMaps(pool, 1, 't9-r1m1')).toEqual(drawMaps(pool, 1, 't9-r1m1')); + expect(pool).toContain(drawMaps(pool, 1, 't9-r1m2')[0]); + // Different matches spread over the pool. + const firsts = new Set( + Array.from({ length: 30 }, (_, i) => drawMaps(pool, 1, `t9-r1m${i}`)[0]) + ); + expect(firsts.size).toBeGreaterThan(2); + // A BO3 from two maps repeats one. + expect(drawMaps(['de_inferno', 'de_nuke'], 3, 'x')).toHaveLength(3); + // A BO3 from a big pool plays three different maps. + expect(new Set(drawMaps(pool, 3, 'y')).size).toBe(3); + } +); + +test('veto off turns the pre-match phase off; on (or unset) keeps it', TAGS, () => { + expect(cs2Integration.usesPreMatchPhase?.({ settings: { cs2: { veto: false } } })).toBe(false); + expect(cs2Integration.usesPreMatchPhase?.({ settings: { cs2: { veto: true } } })).toBe(true); + expect(cs2Integration.usesPreMatchPhase?.({ settings: JSON.stringify({ cs2: {} }) })).toBe(true); +});