From 9889c3ff90f67546b1990d8c4d57b4f2fa9a88ba Mon Sep 17 00:00:00 2001 From: Sivert Date: Mon, 12 Oct 2026 23:04:53 +0200 Subject: [PATCH] feat(tournaments): map veto on or off, a wingman preset, and a veto countdown bar From the 2026-10-12 test: - A BO1/BO3/BO5 tournament always ran a veto and wanted exactly 7 maps. The veto is now a switch (settings.cs2.veto, default on). Off: no veto phase anywhere (start, later rounds, the turn timer), each match plays maps drawn from the pool (the same draw on every config read), and one map or more is enough. - "Set up as wingman" on the maps step: CS2's wingman maps, wingman map type, no veto, 16 rounds. A wingman match is 2v2 whatever the team size says (bot fill fills to two a side). - The veto shows the turn's time to everyone, every step including the side pick: a bar that drains, with the seconds left. Core gets one optional integration hook, usesPreMatchPhase(tournament). Co-Authored-By: Claude Opus 5.5 --- api/src/core/scheduler.ts | 1 + api/src/integrations/cs2/index.ts | 18 +- api/src/integrations/cs2/matchConfig.ts | 37 +- .../integrations/cs2/tournamentSettings.ts | 7 + api/src/integrations/cs2/veto/timer.ts | 1 + api/src/integrations/types.ts | 31 +- api/src/modules/validateIntegration.ts | 1 + api/src/services/tournamentService.ts | 69 +-- .../tournament/setup/TournamentSetup.tsx | 108 ++--- client/src/integrations/cs2/locales/de.json | 8 + client/src/integrations/cs2/locales/en.json | 8 + client/src/integrations/cs2/locales/es.json | 8 + client/src/integrations/cs2/locales/fr.json | 8 + client/src/integrations/cs2/locales/it.json | 8 + client/src/integrations/cs2/locales/nb.json | 8 + client/src/integrations/cs2/locales/nl.json | 8 + client/src/integrations/cs2/locales/pl.json | 8 + .../src/integrations/cs2/locales/pt-PT.json | 8 + .../src/integrations/cs2/locales/zh-CN.json | 8 + .../cs2/setup/Cs2TournamentMapsStep.tsx | 54 ++- .../integrations/cs2/setup/MapPoolStep.tsx | 7 +- .../cs2/setup/cs2TournamentSettings.ts | 10 +- client/src/integrations/cs2/setup/mapRules.ts | 19 +- .../integrations/cs2/veto/VetoInterface.tsx | 392 ++++++++++++------ tests/api/cs2-no-veto.spec.ts | 37 ++ 25 files changed, 654 insertions(+), 218 deletions(-) create mode 100644 tests/api/cs2-no-veto.spec.ts 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); +});