From 83f2ec6c75a13ac4afaef675e6992ec3ffc21685 Mon Sep 17 00:00:00 2001 From: Sivert Date: Mon, 12 Oct 2026 22:26:15 +0200 Subject: [PATCH 1/2] feat(skins): servers follow the platform's skins; esports mode per tournament MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skins showed on the platform but never in the game (2026-10-12): every server needed its own switch and had its skins plugin off. - While the platform's skins are on, every server's skins plugin is on and gets the players' loadouts when they connect; switching skins on or off in the settings reaches every connected server at once (syncSkinsPluginEverywhere). The per-server switch is gone. - A tournament can turn on Esports mode (setup → match settings): its matches play Ready Up's `valve` ruleset (rules.ruleset), Valve's competitive rules with the skins plugin inert. Co-Authored-By: Claude Opus 5.5 --- .../integrations/cs2/fleet/assignConfig.ts | 2 + .../integrations/cs2/fleet/push/controls.ts | 85 +++++++- .../integrations/cs2/fleet/push/pluginSets.ts | 27 ++- api/src/integrations/cs2/fleet/push/skins.ts | 40 ++-- api/src/integrations/cs2/matchConfig.ts | 2 + api/src/integrations/cs2/skins/routes.ts | 52 ++++- api/src/integrations/cs2/skins/skinService.ts | 184 +++++++++++++----- .../integrations/cs2/tournamentSettings.ts | 10 + api/src/types/match.types.ts | 2 + client/src/integrations/cs2/locales/de.json | 4 + client/src/integrations/cs2/locales/en.json | 4 + client/src/integrations/cs2/locales/es.json | 4 + client/src/integrations/cs2/locales/fr.json | 4 + client/src/integrations/cs2/locales/it.json | 4 + client/src/integrations/cs2/locales/nb.json | 4 + client/src/integrations/cs2/locales/nl.json | 4 + client/src/integrations/cs2/locales/pl.json | 4 + .../src/integrations/cs2/locales/pt-PT.json | 4 + .../src/integrations/cs2/locales/zh-CN.json | 4 + .../integrations/cs2/servers/ServerModal.tsx | 121 ++++++------ .../cs2/setup/Cs2MatchSettings.tsx | 20 ++ .../cs2/setup/cs2TournamentSettings.ts | 11 +- 22 files changed, 451 insertions(+), 145 deletions(-) diff --git a/api/src/integrations/cs2/fleet/assignConfig.ts b/api/src/integrations/cs2/fleet/assignConfig.ts index 55ab6ce5e..05cd12b5f 100644 --- a/api/src/integrations/cs2/fleet/assignConfig.ts +++ b/api/src/integrations/cs2/fleet/assignConfig.ts @@ -232,6 +232,8 @@ export function rulesFromMatchConfig( rules.demo = { record, upload: record && defaults.demoUpload === true }; if (config.wingman === true) rules.wingman = true; + // Esports mode (the tournament's setting): Valve's rules, skins plugin inert. + if (config.esports === true) rules.ruleset = 'valve'; if (config.bot_fill === true) { const teamSize = num(config.players_per_team); if (teamSize === undefined || !Number.isInteger(teamSize) || teamSize < 1 || teamSize > 32) diff --git a/api/src/integrations/cs2/fleet/push/controls.ts b/api/src/integrations/cs2/fleet/push/controls.ts index a0ff1e81a..554985bc3 100644 --- a/api/src/integrations/cs2/fleet/push/controls.ts +++ b/api/src/integrations/cs2/fleet/push/controls.ts @@ -185,7 +185,7 @@ async function sendPluginsSet( issuedBy: IssuedBy, fleetCmds: boolean ): Promise<{ id: string; seq: number; delivered: boolean }> { - const value = pluginsSetFor(stored, fleetCmds); + const value = pluginsSetFor(stored, fleetCmds, await platformSkinsOn()); const args: Record = {}; if (value.enable.length > 0) args.enable = value.enable; if (value.disable.length > 0) args.disable = value.disable; @@ -216,7 +216,8 @@ async function linkCreatedOnce(serverId: string): Promise { WHERE s.id = ? AND c.type = 'server.create'`, [serverId] ); - if (!cmd || cmd.issued_by === 'autoscale' || (cmd.issued_by ?? '').startsWith('platform:')) return; + if (!cmd || cmd.issued_by === 'autoscale' || (cmd.issued_by ?? '').startsWith('platform:')) + return; const created = await createdPluginSet(serverId); if (!created.created) return; if (created.set && !created.set.plugins.includes('match')) return; @@ -250,10 +251,15 @@ async function linkKeyEnrolledOnce(serverId: string): Promise { const { linkFleetServer } = await import('../link'); const outcome = await linkFleetServer(serverId); if (!outcome.ok) { - log.warn(`[FLEET] ${serverId}: enrolled with auto-link key ${key.id} but not linked: ${outcome.error}`); + log.warn( + `[FLEET] ${serverId}: enrolled with auto-link key ${key.id} but not linked: ${outcome.error}` + ); return; } - await db.runAsync('UPDATE cs2_fleet_servers SET auto_linked_at = ? WHERE id = ?', [now, serverId]); + await db.runAsync('UPDATE cs2_fleet_servers SET auto_linked_at = ? WHERE id = ?', [ + now, + serverId, + ]); log.info(`[FLEET] ${serverId}: enrolled with auto-link key ${key.id}; joined the match pool`); } @@ -278,7 +284,10 @@ export async function pluginsOnHello( if (!prefs.plugins) { if (prefs.pushed.plugins) return null; const created = await createdPluginSet(serverId); - if (!created.set) return null; + if (!created.set) { + const state = pluginsStateOf(hello); + return state && (await skinsFollowPlatform(serverId, state)) ? 'resync' : null; + } await setPlugins(serverId, pluginSetCommand(created.set), PLATFORM_ISSUER, 'platform'); log.info( `[FLEET] ${serverId}: plugin set ${created.set.preset} (${created.set.plugins.join(', ')}) sent after its first hello` @@ -287,13 +296,16 @@ export async function pluginsOnHello( } const state = pluginsStateOf(hello); if (!state) return null; + // No set of ours yet: the skins plugin still follows the platform. + if (!prefs.plugins) return (await skinsFollowPlatform(serverId, state)) ? 'resync' : null; const last = prefs.pushed.plugins; if (last?.seq !== undefined && last.seq > hello.stream.last_rx_seq) return null; // This hello's capabilities: the stored ones may still be the previous Ready Up's. const fleetCmds = Array.isArray(hello.capabilities) ? hello.capabilities.includes(FLEET_CMDS_CAPABILITY) : await runsFleetCmds(serverId); - if (!pluginsStateDiffers(pluginsSetFor(prefs.plugins, fleetCmds), state)) return null; + if (!pluginsStateDiffers(pluginsSetFor(prefs.plugins, fleetCmds, await platformSkinsOn()), state)) + return null; await sendPluginsSet(serverId, prefs.plugins, PLATFORM_ISSUER, fleetCmds); log.info(`[FLEET] ${serverId}: its plugins differ from its plugin set; plugins.set sent again`); // A practice server that now runs without match (e.g. after a Ready Up @@ -308,3 +320,64 @@ export async function pluginsOnHello( } return 'resync'; } + +/** The platform's skins are on (Skins settings). */ +async function platformSkinsOn(): Promise { + const { skinService } = await import('../../skins/skinService'); + return (await skinService.config()).enabled === true; +} + +/** + * The skins plugin follows the platform on a server we never gave a plugin + * set: on while the platform's skins are on (and the server has it + * installed), off when they are off. True when a plugins.set went out. + */ +async function skinsFollowPlatform( + serverId: string, + state: { installed: string[]; disabled: string[] } +): Promise { + if (!state.installed.includes('skins')) return false; + const on = await platformSkinsOn(); + const isOff = state.disabled.includes('skins'); + if (on === !isOff) return false; + await sendCmd(serverId, { + name: 'plugins.set', + args: on ? { enable: ['skins'] } : { disable: ['skins'] }, + issued_by: PLATFORM_ISSUER, + expires_at: 0, + }); + log.info( + `[FLEET] ${serverId}: skins plugin turned ${on ? 'on' : 'off'} (the platform's skins are ${on ? 'on' : 'off'})` + ); + return true; +} + +/** + * The platform's skins were switched on or off: every connected server's + * skins plugin follows now (its stored set, or just the skins plugin). + */ +export async function syncSkinsPluginEverywhere(): Promise { + const { fleetBus } = await import('../service'); + const bus = fleetBus(); + const rows = await db.queryAsync<{ id: string; plugins_state: string | null }>( + 'SELECT id, plugins_state FROM cs2_fleet_servers' + ); + for (const row of rows) { + if (!bus.isConnected(row.id)) continue; + try { + const prefs = await readPrefs(row.id); + if (prefs.plugins) { + await sendPluginsSet(row.id, prefs.plugins, PLATFORM_ISSUER, await runsFleetCmds(row.id)); + continue; + } + const state = row.plugins_state + ? (JSON.parse(row.plugins_state) as { installed?: string[]; disabled?: string[] }) + : null; + if (state && Array.isArray(state.installed) && Array.isArray(state.disabled)) { + await skinsFollowPlatform(row.id, { installed: state.installed, disabled: state.disabled }); + } + } catch (error) { + log.warn(`[FLEET] ${row.id}: skins plugin not synced`, { error: (error as Error).message }); + } + } +} diff --git a/api/src/integrations/cs2/fleet/push/pluginSets.ts b/api/src/integrations/cs2/fleet/push/pluginSets.ts index 6e8685de6..aa5eab69f 100644 --- a/api/src/integrations/cs2/fleet/push/pluginSets.ts +++ b/api/src/integrations/cs2/fleet/push/pluginSets.ts @@ -191,10 +191,29 @@ export async function runsFleetCmds(fleetServerId: string): Promise { } } -/** A plugins.set for a server: older Ready Up refuses to disable match, so match stays on there. */ -export function pluginsSetFor(value: StoredPlugins, fleetCmds: boolean): StoredPlugins { - if (fleetCmds || !value.disable.includes('match')) return value; - return { ...value, disable: value.disable.filter((n) => n !== 'match') }; +/** + * A plugins.set for a server: older Ready Up refuses to disable match, so + * match stays on there; with the platform's skins on, the skins plugin is on + * whatever the set says (skins follow the platform; a tournament in esports + * mode turns them off per match, rules.ruleset 'valve'). + */ +export function pluginsSetFor( + value: StoredPlugins, + fleetCmds: boolean, + skinsOn = false +): StoredPlugins { + let out = value; + if (!fleetCmds && out.disable.includes('match')) { + out = { ...out, disable: out.disable.filter((n) => n !== 'match') }; + } + if (skinsOn && !out.enable.includes('skins')) { + out = { + ...out, + enable: [...out.enable, 'skins'], + disable: out.disable.filter((n) => n !== 'skins'), + }; + } + return out; } /** The server's stored plugin set turns match off: it is not for matches (a practice server). */ diff --git a/api/src/integrations/cs2/fleet/push/skins.ts b/api/src/integrations/cs2/fleet/push/skins.ts index 6b8cbc995..7cfd38cff 100644 --- a/api/src/integrations/cs2/fleet/push/skins.ts +++ b/api/src/integrations/cs2/fleet/push/skins.ts @@ -4,13 +4,13 @@ * a loadout only reaches the server in warmup, between maps and around the * match, so nobody's weapons change mid-round. * - * Only to a server that - * - has the skins plugin (`skins.v1` in its hello capabilities), - * - runs without a Valve game server token (`hello.host.steam_token`): Valve - * bans the token, and the account behind it, of a server that hands out - * items players do not own; the plugin stays inert there too, - * - an admin turned skins on for (`cs2_servers.skins`), - * and only while skins are on for the platform (`skins_config.enabled`). + * Every server follows the platform: while skins are on there + * (`skins_config.enabled`) each server's skins plugin is on + * (./controls.ts syncSkinsPluginEverywhere) and gets the players' loadouts. + * Only to a server that has the plugin running (`skins.v1` in its hello + * capabilities) and no Steam server token (`hello.host.steam_token`; the + * plugin itself stays inert with one). A tournament in esports mode plays + * Ready Up's `valve` ruleset, where the plugin is inert for that match. */ import { db } from '../../../../config/database'; @@ -24,15 +24,14 @@ import type { SkinsLoadoutPayload } from '../protocol/v1'; const OPEN_PHASES = new Set(['loading', 'warmup', 'map_end', 'series_end', 'restoring']); /** Whether this fleet server takes skins, and why not when it does not. */ -export async function skinsTarget(fleetServerId: string): Promise<{ ok: true } | { ok: false; reason: string }> { - const row = await db.queryOneAsync<{ capabilities: string | null; host: string | null; skins: number | null }>( - `SELECT f.capabilities, f.host, s.skins - FROM cs2_fleet_servers f LEFT JOIN cs2_servers s ON s.fleet_server_id = f.id - WHERE f.id = ?`, +export async function skinsTarget( + fleetServerId: string +): Promise<{ ok: true } | { ok: false; reason: string }> { + const row = await db.queryOneAsync<{ capabilities: string | null; host: string | null }>( + `SELECT f.capabilities, f.host FROM cs2_fleet_servers f WHERE f.id = ?`, [fleetServerId] ); if (!row) return { ok: false, reason: 'unknown server' }; - if (row.skins !== 1) return { ok: false, reason: 'skins off for this server' }; const caps = (() => { try { return JSON.parse(row.capabilities ?? '[]') as string[]; @@ -48,12 +47,17 @@ export async function skinsTarget(fleetServerId: string): Promise<{ ok: true } | return {}; } })(); - if (host.steam_token === true) return { ok: false, reason: 'the server uses a Steam server token' }; + if (host.steam_token === true) + return { ok: false, reason: 'the server uses a Steam server token' }; return { ok: true }; } /** A player connected to a match on a fleet server: send their loadout if the map is not being played. */ -export async function pushSkinsOnConnect(fleetServerId: string, matchSlug: string, steamid64: string): Promise { +export async function pushSkinsOnConnect( + fleetServerId: string, + matchSlug: string, + steamid64: string +): Promise { try { const target = await skinsTarget(fleetServerId); if (!target.ok) return; @@ -67,6 +71,10 @@ export async function pushSkinsOnConnect(fleetServerId: string, matchSlug: strin const payload: SkinsLoadoutPayload = { steamid64, rev: Math.floor(Date.now() / 1000), items }; await sendReliable(fleetServerId, { type: 'skins.loadout', payload }); } catch (error) { - log.warn('[FLEET] skins: loadout not sent', { fleetServerId, matchSlug, error: (error as Error).message }); + log.warn('[FLEET] skins: loadout not sent', { + fleetServerId, + matchSlug, + error: (error as Error).message, + }); } } diff --git a/api/src/integrations/cs2/matchConfig.ts b/api/src/integrations/cs2/matchConfig.ts index 69e7ef364..eb748ff93 100644 --- a/api/src/integrations/cs2/matchConfig.ts +++ b/api/src/integrations/cs2/matchConfig.ts @@ -336,6 +336,7 @@ export const generateMatchConfig = async ( } : { name: 'TBD', tag: 'TBD', players: {}, series_score: 0 }, ...(fillBots ? { bot_fill: true } : {}), + ...(storedCs2Settings(tournament.settings)?.esportsMode === true ? { esports: true } : {}), simulation, simulation_timescale: simulation ? (simulationTimescale ?? 1) : undefined, }; @@ -538,6 +539,7 @@ async function generateShuffleMatchConfig( } : { name: 'TBD', tag: 'TBD', players: {}, series_score: 0 }, ...(fillBots ? { bot_fill: true } : {}), + ...(storedCs2Settings(tournament.settings)?.esportsMode === true ? { esports: true } : {}), simulation, simulation_timescale: simulation ? (simulationTimescale ?? 1) : undefined, }; diff --git a/api/src/integrations/cs2/skins/routes.ts b/api/src/integrations/cs2/skins/routes.ts index 15281a348..5d9fe4c42 100644 --- a/api/src/integrations/cs2/skins/routes.ts +++ b/api/src/integrations/cs2/skins/routes.ts @@ -18,12 +18,19 @@ import { Router, type Request, type Response } from 'express'; import { requireAuth } from '../../../middleware/auth'; import { log } from '../../../utils/logger'; import { resolveViewerAccount } from '../../../utils/viewerIdentity'; -import { SKIN_IMAGES_BASE, SkinError, skinService, variantOf, type SkinsConfig } from './skinService'; +import { + SKIN_IMAGES_BASE, + SkinError, + skinService, + variantOf, + type SkinsConfig, +} from './skinService'; const router = Router(); function fail(res: Response, error: unknown, what: string) { - if (error instanceof SkinError) return res.status(error.status).json({ success: false, error: error.message }); + if (error instanceof SkinError) + return res.status(error.status).json({ success: false, error: error.message }); log.error(`[Skins] ${what} failed`, { error }); return res.status(500).json({ success: false, error: `${what} failed` }); } @@ -154,7 +161,9 @@ router.post('/me/seen', async (req, res) => { try { const uid = await playerOrStop(req, res); if (!uid) return; - const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number).filter(Number.isInteger) : []; + const ids = Array.isArray(req.body?.ids) + ? req.body.ids.map(Number).filter(Number.isInteger) + : []; await skinService.markSeen(uid, ids); return res.json({ success: true }); } catch (error) { @@ -205,7 +214,8 @@ router.put('/me/showcase', async (req, res) => { */ router.get('/players/:steamId', async (req, res) => { try { - if (!(await skinService.config()).enabled) return res.status(404).json({ success: false, error: 'Skins are off' }); + if (!(await skinService.config()).enabled) + return res.status(404).json({ success: false, error: 'Skins are off' }); const profile = await skinService.publicProfile(req.params.steamId); if (!profile) return res.status(404).json({ success: false, error: 'No such player' }); return res.json({ success: true, ...profile }); @@ -228,7 +238,8 @@ router.get('/players/:steamId', async (req, res) => { */ router.get('/skin/:id', async (req, res) => { try { - if (!(await skinService.config()).enabled) return res.status(404).json({ success: false, error: 'Skins are off' }); + if (!(await skinService.config()).enabled) + return res.status(404).json({ success: false, error: 'Skins are off' }); return res.json({ success: true, ...(await skinService.one(Number(req.params.id))) }); } catch (error) { return fail(res, error, 'Reading a skin'); @@ -264,7 +275,14 @@ router.get('/admin/config', requireAuth, async (_req, res) => { }); router.put('/admin/config', requireAuth, async (req, res) => { try { + const before = (await skinService.config()).enabled; const config = await skinService.setConfig((req.body ?? {}) as Partial); + // The servers' skins plugin follows the platform's switch. + if (config.enabled !== before) { + void import('../fleet/push/controls').then(({ syncSkinsPluginEverywhere }) => + syncSkinsPluginEverywhere() + ); + } return res.json({ success: true, config }); } catch (error) { return fail(res, error, 'Saving the skins settings'); @@ -286,12 +304,20 @@ router.put('/admin/config', requireAuth, async (req, res) => { */ router.get('/admin/catalog', requireAuth, async (req, res) => { try { - const words = String(req.query.q ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean); + const words = String(req.query.q ?? '') + .trim() + .toLowerCase() + .split(/\s+/) + .filter(Boolean); // `all=1`: every paint kit, so each phase of a Doppler is its own row. const all = req.query.all === '1'; const seen = new Set(); const skins = (await skinService.catalog()) - .map((s) => ({ ...s, variant: variantOf(s.paintKitName), imageUrl: `${SKIN_IMAGES_BASE}/${s.image}` })) + .map((s) => ({ + ...s, + variant: variantOf(s.paintKitName), + imageUrl: `${SKIN_IMAGES_BASE}/${s.image}`, + })) .filter((s) => { const text = `${s.weaponName} ${s.name} ${s.variant ?? ''}`.toLowerCase(); return words.every((w) => text.includes(w)); @@ -349,7 +375,9 @@ router.get('/admin/players', requireAuth, async (req, res) => { name: p.name, avatarUrl: p.avatar_url, skins: p.skins, - best: p.best_name ? { weaponName: p.best_weapon, name: p.best_name, rarity: p.best_rarity } : null, + best: p.best_name + ? { weaponName: p.best_weapon, name: p.best_name, rarity: p.best_rarity } + : null, })), }); } catch (error) { @@ -372,7 +400,10 @@ router.get('/admin/players', requireAuth, async (req, res) => { */ router.get('/admin/players/:steamId/inventory', requireAuth, async (req, res) => { try { - return res.json({ success: true, inventory: await skinService.adminInventory(req.params.steamId) }); + return res.json({ + success: true, + inventory: await skinService.adminInventory(req.params.steamId), + }); } catch (error) { return fail(res, error, 'Reading the inventory'); } @@ -439,7 +470,8 @@ router.post('/admin/players/:steamId/skins', requireAuth, async (req, res) => { router.delete('/admin/skins/:id', requireAuth, async (req, res) => { try { const id = Number(req.params.id); - if (!Number.isInteger(id)) return res.status(400).json({ success: false, error: 'Bad skin id.' }); + if (!Number.isInteger(id)) + return res.status(400).json({ success: false, error: 'Bad skin id.' }); await skinService.adminRemove(id); return res.json({ success: true }); } catch (error) { diff --git a/api/src/integrations/cs2/skins/skinService.ts b/api/src/integrations/cs2/skins/skinService.ts index cbc60b9d8..da4f0a205 100644 --- a/api/src/integrations/cs2/skins/skinService.ts +++ b/api/src/integrations/cs2/skins/skinService.ts @@ -1,11 +1,10 @@ /** - * Virtual skins: an inventory per player, earned by winning matchmaking games - * and placing in tournaments, shown on the platform only. - * - * Skins never reach a game server. Valve bans the server token of a community - * server that lets players use items they do not own, so the inventory, the - * equipped loadout and the profile showcase live here and nowhere else. The - * notice that says so is fixed text in the client and cannot be edited. + * Skins: an inventory per player, earned by winning matchmaking games and + * placing in tournaments, shown on the platform and played in the servers: + * the equipped loadout goes to Ready Up's skins plugin when a player + * connects (../fleet/push/skins.ts), on every server while skins are on here. + * A tournament in esports mode plays without them (Ready Up's `valve` + * ruleset). * * Everything is off until an admin turns it on (`skins_config.enabled`). * @@ -32,7 +31,15 @@ export const SKINS_JSON_URL = const CONFIG_KEY = 'skins_config'; const CATALOG_TTL_MS = 60 * 60 * 1000; -export const RARITIES = ['common', 'uncommon', 'rare', 'mythical', 'legendary', 'ancient', 'immortal'] as const; +export const RARITIES = [ + 'common', + 'uncommon', + 'rare', + 'mythical', + 'legendary', + 'ancient', + 'immortal', +] as const; export type Rarity = (typeof RARITIES)[number]; export interface CatalogSkin { @@ -58,7 +65,12 @@ export function variantOf(paintKitName: string | undefined): string | null { // Not "emerald" anywhere: specialist_emerald_web is a glove, an_emerald a plain finish. const name = (paintKitName ?? '').toLowerCase(); const gem = /^am_(ruby|sapphire|blackpearl|emerald)_marbleized/.exec(name); - if (gem) return { ruby: 'Ruby', sapphire: 'Sapphire', blackpearl: 'Black Pearl', emerald: 'Emerald' }[gem[1]] ?? null; + if (gem) + return ( + { ruby: 'Ruby', sapphire: 'Sapphire', blackpearl: 'Black Pearl', emerald: 'Emerald' }[ + gem[1] + ] ?? null + ); const phase = /^am_(?:gamma_)?doppler_phase(\d)/.exec(name); return phase ? `Phase ${phase[1]}` : null; } @@ -96,7 +108,15 @@ export const DEFAULT_CONFIG: SkinsConfig = { matchmakingDrops: true, dropChance: 20, playDropChance: 5, - rarityWeights: { common: 40, uncommon: 25, rare: 18, mythical: 10, legendary: 5, ancient: 2, immortal: 0 }, + rarityWeights: { + common: 40, + uncommon: 25, + rare: 18, + mythical: 10, + legendary: 5, + ancient: 2, + immortal: 0, + }, tournamentRewards: true, rewards: [ { place: 1, mode: 'random', rarity: 'legendary' }, @@ -176,7 +196,9 @@ export function rollPattern(seeds?: number[]): number { /** A rarity drawn by weight; null when every weight is zero. */ export function rollRarity(weights: Record): Rarity | null { - const entries = RARITIES.map((r) => [r, Math.max(0, weights[r] ?? 0)] as const).filter(([, w]) => w > 0); + const entries = RARITIES.map((r) => [r, Math.max(0, weights[r] ?? 0)] as const).filter( + ([, w]) => w > 0 + ); const total = entries.reduce((sum, [, w]) => sum + w, 0); if (total <= 0) return null; let pick = randomInt(0, Math.round(total * 1000)) / 1000; @@ -215,7 +237,8 @@ function normalizeConfig(raw: Partial): SkinsConfig { const weights = { ...DEFAULT_CONFIG.rarityWeights }; for (const r of RARITIES) { const value = raw.rarityWeights?.[r]; - if (typeof value === 'number' && Number.isFinite(value)) weights[r] = Math.max(0, Math.min(1000, value)); + if (typeof value === 'number' && Number.isFinite(value)) + weights[r] = Math.max(0, Math.min(1000, value)); } const rewards = (Array.isArray(raw.rewards) ? raw.rewards : DEFAULT_CONFIG.rewards) .filter((r): r is TournamentReward => Boolean(r) && [1, 2, 3].includes(Number(r.place))) @@ -227,7 +250,9 @@ function normalizeConfig(raw: Partial): SkinsConfig { name: typeof r.name === 'string' ? r.name : undefined, floatMin: typeof r.floatMin === 'number' ? Math.max(0, Math.min(1, r.floatMin)) : undefined, floatMax: typeof r.floatMax === 'number' ? Math.max(0, Math.min(1, r.floatMax)) : undefined, - seeds: Array.isArray(r.seeds) ? r.seeds.filter((s) => Number.isInteger(s) && s >= 0 && s <= 1000).slice(0, 100) : [], + seeds: Array.isArray(r.seeds) + ? r.seeds.filter((s) => Number.isInteger(s) && s >= 0 && s <= 1000).slice(0, 100) + : [], })) as TournamentReward[]; return { enabled: raw.enabled === true, @@ -254,7 +279,9 @@ function normalizeConfig(raw: Partial): SkinsConfig { function withPriceRarity(skins: CatalogSkin[], prices: Record): CatalogSkin[] { if (Object.keys(prices).length === 0) return skins; const keyOf = (s: CatalogSkin) => priceKey(s.weaponName, s.name, variantOf(s.paintKitName)); - const special = skins.filter((s) => slotOf(s.weapon) === 'knife' || slotOf(s.weapon) === 'gloves'); + const special = skins.filter( + (s) => slotOf(s.weapon) === 'knife' || slotOf(s.weapon) === 'gloves' + ); const bands = RARITIES.indexOf('ancient') + 1; const index = rarityIndexByPrice(prices, special.map(keyOf), bands); return skins.map((s) => { @@ -263,12 +290,13 @@ function withPriceRarity(skins: CatalogSkin[], prices: Record): const key = keyOf(s); const band = index.get(key); return band === undefined ? s : { ...s, rarity: RARITIES[band], price: priceFor(prices, key) }; - }); } async function uidOf(steamId: string): Promise { - const row = await db.queryOneAsync<{ uid: string }>('SELECT uid FROM players WHERE id = ?', [steamId]); + const row = await db.queryOneAsync<{ uid: string }>('SELECT uid FROM players WHERE id = ?', [ + steamId, + ]); return row?.uid ?? null; } @@ -299,7 +327,9 @@ export const skinService = { try { const response = await fetch(SKINS_JSON_URL, { // node-fetch 2 declares its own AbortSignal type; Node's has the same shape. - signal: controller.signal as unknown as NonNullable[1]>['signal'], + signal: controller.signal as unknown as NonNullable< + Parameters[1] + >['signal'], }); if (!response.ok) throw new Error(`HTTP ${response.status}`); body = (await response.json()) as { skins?: CatalogSkin[] }; @@ -308,7 +338,11 @@ export const skinService = { } const skins = withPriceRarity( (body.skins ?? []).filter( - (s) => s && typeof s.weapon === 'string' && RARITIES.includes(s.rarity) && typeof s.image === 'string' + (s) => + s && + typeof s.weapon === 'string' && + RARITIES.includes(s.rarity) && + typeof s.image === 'string' ), await knifeAndGlovePrices() ); @@ -361,14 +395,23 @@ export const skinService = { await this.addToShowcase(playerUid, row.id, slotOf(skin.weapon)); } // The reveal shows at once on any page the player has open. - const player = await db.queryOneAsync<{ id: string }>('SELECT id FROM players WHERE uid = ?', [playerUid]); + const player = await db.queryOneAsync<{ id: string }>('SELECT id FROM players WHERE uid = ?', [ + playerUid, + ]); if (player) { emitSkinsChanged(player.id); if (row?.id) { await notificationService.notify( player.id, 'skin', - { skinId: row.id, name: skin.name, weapon: skin.weaponName, rarity: skin.rarity, image: skin.image, source: source.label ?? source.source }, + { + skinId: row.id, + name: skin.name, + weapon: skin.weaponName, + rarity: skin.rarity, + image: skin.image, + source: source.label ?? source.source, + }, `skin:${row.id}` ); } @@ -429,7 +472,10 @@ export const skinService = { const config = await this.config(); if (!config.enabled || !config.tournamentRewards || rewardsOff) return; const ref = `tournament:${tournamentId}:${tournamentName}`; - const already = await db.queryOneAsync('SELECT id FROM cs2_player_skins WHERE source_ref = ? LIMIT 1', [ref]); + const already = await db.queryOneAsync( + 'SELECT id FROM cs2_player_skins WHERE source_ref = ? LIMIT 1', + [ref] + ); if (already) return; const catalog = await this.catalog(); for (const { place, steamIds } of placements) { @@ -441,7 +487,9 @@ export const skinService = { let skin: CatalogSkin | null = null; if (reward.mode === 'skin' && reward.weapon && reward.name) { // One name can be several paint kits (Doppler's phases): one at random, like a case. - const variants = catalog.filter((s) => s.weapon === reward.weapon && s.name === reward.name); + const variants = catalog.filter( + (s) => s.weapon === reward.weapon && s.name === reward.name + ); skin = variants.length ? variants[randomInt(0, variants.length)] : null; } else { skin = await this.pick(reward.rarity ?? 'rare'); @@ -456,7 +504,10 @@ export const skinService = { } } } catch (error) { - log.warn('[Skins] Tournament rewards failed', { tournamentId, error: (error as Error).message }); + log.warn('[Skins] Tournament rewards failed', { + tournamentId, + error: (error as Error).message, + }); } }, @@ -475,10 +526,10 @@ export const skinService = { /** Equips a skin in its slot, replacing what was there. */ async equip(playerUid: string, skinId: number): Promise { - const skin = await db.queryOneAsync('SELECT * FROM cs2_player_skins WHERE id = ? AND player_uid = ?', [ - skinId, - playerUid, - ]); + const skin = await db.queryOneAsync( + 'SELECT * FROM cs2_player_skins WHERE id = ? AND player_uid = ?', + [skinId, playerUid] + ); if (!skin) throw new SkinError(404, 'That skin is not in your inventory.'); await db.runAsync( `INSERT INTO cs2_player_loadout (player_uid, slot, skin_id) VALUES (?, ?, ?) @@ -488,15 +539,18 @@ export const skinService = { }, async unequip(playerUid: string, slot: string): Promise { - await db.runAsync('DELETE FROM cs2_player_loadout WHERE player_uid = ? AND slot = ?', [playerUid, slot]); + await db.runAsync('DELETE FROM cs2_player_loadout WHERE player_uid = ? AND slot = ?', [ + playerUid, + slot, + ]); }, async markSeen(playerUid: string, ids: number[]): Promise { if (!ids.length) return; - await db.runAsync('UPDATE cs2_player_skins SET seen = TRUE WHERE player_uid = ? AND id = ANY(?::int[])', [ - playerUid, - ids, - ]); + await db.runAsync( + 'UPDATE cs2_player_skins SET seen = TRUE WHERE player_uid = ? AND id = ANY(?::int[])', + [playerUid, ids] + ); }, /** The profile showcase: up to eight equipped skins in the owner's order, some big. */ @@ -526,18 +580,26 @@ export const skinService = { ); let items = await this.showcase(playerUid); if (!saved) { - const equipped = (await this.inventory(playerUid)).filter((s) => s.equipped && s.id !== skinId); + const equipped = (await this.inventory(playerUid)).filter( + (s) => s.equipped && s.id !== skinId + ); const isBig = (s: { slot: string }) => s.slot === 'knife' || s.slot === 'gloves'; items = [...equipped.filter(isBig), ...equipped.filter((s) => !isBig(s))].map((s) => ({ skinId: s.id, big: isBig(s), })); } - const next = [{ skinId, big: slot === 'knife' || slot === 'gloves' }, ...items.filter((i) => i.skinId !== skinId)]; + const next = [ + { skinId, big: slot === 'knife' || slot === 'gloves' }, + ...items.filter((i) => i.skinId !== skinId), + ]; await this.setShowcase(playerUid, next); }, - async setShowcase(playerUid: string, items: Array<{ skinId: number; big: boolean }>): Promise { + async setShowcase( + playerUid: string, + items: Array<{ skinId: number; big: boolean }> + ): Promise { const owned = new Set((await this.inventory(playerUid)).map((s) => s.id)); const clean = items .filter((i) => owned.has(Number(i.skinId))) @@ -572,13 +634,20 @@ export const skinService = { if (!(await this.config()).enabled) return null; const uid = await uidOf(steamId); if (!uid) return null; - const rows = await db.queryAsync<{ weapon: string; paint_kit: number; float_value: number; pattern: number; slot: string }>( + const rows = await db.queryAsync<{ + weapon: string; + paint_kit: number; + float_value: number; + pattern: number; + slot: string; + }>( `SELECT s.weapon, s.paint_kit, s.float_value, s.pattern, l.slot FROM cs2_player_loadout l JOIN cs2_player_skins s ON s.id = l.skin_id WHERE l.player_uid = ?`, [uid] ); - const paints: Array<{ team: 0; defindex: number; paint: number; wear: number; seed: number }> = []; + const paints: Array<{ team: 0; defindex: number; paint: number; wear: number; seed: number }> = + []; let knife: Array<{ team: 0; defindex: number }> | undefined; let gloves: Array<{ team: 0; defindex: number }> | undefined; for (const row of rows) { @@ -601,7 +670,12 @@ export const skinService = { /** Admin: the numbers at the top of the Skins page. */ async adminStats() { const weekAgo = Math.floor(Date.now() / 1000) - 7 * 24 * 3600; - const counts = await db.queryOneAsync<{ given: number; week: number; owners: number; players: number }>( + const counts = await db.queryOneAsync<{ + given: number; + week: number; + owners: number; + players: number; + }>( `SELECT (SELECT COUNT(*)::int FROM cs2_player_skins) AS given, (SELECT COUNT(*)::int FROM cs2_player_skins WHERE created_at > ?) AS week, (SELECT COUNT(DISTINCT player_uid)::int FROM cs2_player_skins) AS owners, @@ -609,7 +683,13 @@ export const skinService = { [weekAgo] ); // The rarest skin anyone owns: highest rarity, the newest of those. - const rarest = await db.queryOneAsync<{ weapon_name: string; name: string; rarity: Rarity; image: string; owner: string | null }>( + const rarest = await db.queryOneAsync<{ + weapon_name: string; + name: string; + rarity: Rarity; + image: string; + owner: string | null; + }>( `SELECT s.weapon_name, s.name, s.rarity, s.image, p.name AS owner FROM cs2_player_skins s LEFT JOIN players p ON p.uid = s.player_uid @@ -677,12 +757,19 @@ export const skinService = { ): Promise { const uid = await uidOf(steamId); if (!uid) throw new SkinError(404, 'No such player.'); - const skin = (await this.catalog()).find((s) => s.weapon === input.weapon && s.paintKit === input.paintKit); + const skin = (await this.catalog()).find( + (s) => s.weapon === input.weapon && s.paintKit === input.paintKit + ); if (!skin) throw new SkinError(400, 'That skin is not in the catalogue.'); const float = - typeof input.float === 'number' && Number.isFinite(input.float) ? Math.max(0, Math.min(1, input.float)) : null; + typeof input.float === 'number' && Number.isFinite(input.float) + ? Math.max(0, Math.min(1, input.float)) + : null; const pattern = - typeof input.pattern === 'number' && Number.isInteger(input.pattern) && input.pattern >= 0 && input.pattern <= 1000 + typeof input.pattern === 'number' && + Number.isInteger(input.pattern) && + input.pattern >= 0 && + input.pattern <= 1000 ? input.pattern : null; return this.grant( @@ -695,20 +782,27 @@ export const skinService = { /** Admin: take a skin away (it leaves the loadout and the showcase with it). */ async adminRemove(skinId: number): Promise { - const row = await db.queryOneAsync<{ id: number }>('SELECT id FROM cs2_player_skins WHERE id = ?', [skinId]); + const row = await db.queryOneAsync<{ id: number }>( + 'SELECT id FROM cs2_player_skins WHERE id = ?', + [skinId] + ); if (!row) throw new SkinError(404, 'No such skin.'); await db.runAsync('DELETE FROM cs2_player_skins WHERE id = ?', [skinId]); }, /** One skin with its owner, for the inspect dialog. */ async one(skinId: number) { - const row = await db.queryOneAsync( + const row = await db.queryOneAsync< + SkinRow & { owner_id: string; owner_name: string; owner_avatar: string | null } + >( `SELECT s.*, p.id AS owner_id, p.name AS owner_name, p.avatar_url AS owner_avatar FROM cs2_player_skins s JOIN players p ON p.uid = s.player_uid WHERE s.id = ?`, [skinId] ); if (!row) throw new SkinError(404, 'No such skin.'); - const equipped = await db.queryOneAsync('SELECT 1 FROM cs2_player_loadout WHERE skin_id = ?', [skinId]); + const equipped = await db.queryOneAsync('SELECT 1 FROM cs2_player_loadout WHERE skin_id = ?', [ + skinId, + ]); return { skin: toRow(row, new Set(equipped ? [skinId] : [])), owner: { steamId: row.owner_id, name: row.owner_name, avatar: row.owner_avatar }, diff --git a/api/src/integrations/cs2/tournamentSettings.ts b/api/src/integrations/cs2/tournamentSettings.ts index 57993661f..f126e33b2 100644 --- a/api/src/integrations/cs2/tournamentSettings.ts +++ b/api/src/integrations/cs2/tournamentSettings.ts @@ -61,6 +61,11 @@ export interface Cs2TournamentSettings { skinRewards?: boolean; /** Opt-in anonymous bots up to teamSize; humans keep normal ready-up. */ fillTeamsWithBots?: boolean; + /** + * Esports mode: Ready Up's `valve` ruleset (docs/ESPORTS-MODE.md): Valve's + * competitive rules, no skins in the server. Off: normal rules, skins on. + */ + esportsMode?: boolean; } /** The map pool size a veto order is checked against when nothing else says (Active Duty). */ @@ -234,6 +239,9 @@ export function validateCs2TournamentSettings( : objectIn(input.body?.settings ?? input.settings, CS2_SETTINGS_KEY).fillTeamsWithBots; if (fill !== undefined && typeof fill !== 'boolean') return done({ fieldErrors: ['fillTeamsWithBots must be a boolean'] }); + const esports = objectIn(input.body?.settings ?? input.settings, CS2_SETTINGS_KEY).esportsMode; + if (esports !== undefined && typeof esports !== 'boolean') + return done({ fieldErrors: ['esportsMode must be a boolean'] }); switch (input.mode) { case 'create': return validateCreate(input); @@ -300,6 +308,7 @@ function applyLayer( if (typeof layer.fillTeamsWithBots === 'boolean') into.fillTeamsWithBots = layer.fillTeamsWithBots; if (typeof layer.skinRewards === 'boolean') into.skinRewards = layer.skinRewards; + if (typeof layer.esportsMode === 'boolean') into.esportsMode = layer.esportsMode; if (layer.mapPoolId !== undefined) { const id = Number(layer.mapPoolId); if (layer.mapPoolId !== null && Number.isInteger(id) && id > 0) into.mapPoolId = id; @@ -344,6 +353,7 @@ export const cs2TournamentSettings: ModuleTournamentSettings { console.log('handleSave called', { name, host, port, password: '***' }); console.log('Host value length:', host.length, 'Host trimmed length:', host.trim().length); - + if (!name.trim()) { console.log('Validation failed: name required'); setError(t('serverModal.errors.nameRequired')); @@ -132,15 +138,16 @@ export default function ServerModal({ open, server, servers, onClose, onSave }: try { const trimmedHost = host.trim(); - + // Check for existing server with same IP:port (excluding current server when editing) const existingServerByHostPort = servers.find( - (s) => s.host === trimmedHost && s.port === portNum && s.id !== (isEditing ? server?.id : '') + (s) => + s.host === trimmedHost && s.port === portNum && s.id !== (isEditing ? server?.id : '') ); // If found, use its ID to update it; otherwise generate new ID - const serverId = isEditing - ? server.id + const serverId = isEditing + ? server.id : existingServerByHostPort?.id || slugifyServerName(name); if (!serverId) { @@ -152,7 +159,9 @@ export default function ServerModal({ open, server, servers, onClose, onSave }: // When creating, check if the generated ID conflicts with a different server (not by IP:port) if (!isEditing && !existingServerByHostPort) { - const idConflict = servers.find((s) => s.id === serverId && (s.host !== trimmedHost || s.port !== portNum)); + const idConflict = servers.find( + (s) => s.id === serverId && (s.host !== trimmedHost || s.port !== portNum) + ); if (idConflict) { console.log('Validation failed: duplicate server ID', serverId); setError(t('serverModal.errors.duplicateId', { id: serverId })); @@ -178,7 +187,9 @@ export default function ServerModal({ open, server, servers, onClose, onSave }: console.log('Updating existing server:', server.id); await api.put(`/api/servers/${server.id}`, { name: payload.name, - ...(isFleet ? {} : { host: payload.host, port: payload.port, password: payload.password }), + ...(isFleet + ? {} + : { host: payload.host, port: payload.port, password: payload.password }), enabled: payload.enabled, tournamentUse: payload.tournamentUse, skins: payload.skins, @@ -349,7 +360,13 @@ export default function ServerModal({ open, server, servers, onClose, onSave }: placeholder={t('serverModal.hostPlaceholder')} required={!isFleet} disabled={isFleet} - helperText={isFleet ? t('serverModal.fleetAddress', { defaultValue: 'Reported by Ready Up over the fleet link.' }) : undefined} + helperText={ + isFleet + ? t('serverModal.fleetAddress', { + defaultValue: 'Reported by Ready Up over the fleet link.', + }) + : undefined + } fullWidth slotProps={{ htmlInput: { 'data-testid': 'server-host-input' }, @@ -374,35 +391,35 @@ export default function ServerModal({ open, server, servers, onClose, onSave }: /> {!isFleet && ( - { - setPassword(e.target.value); - if (error) setError(''); // Clear error when user starts typing - }} - placeholder={t('serverModal.rconPasswordPlaceholder')} - type={showPassword ? 'text' : 'password'} - required - fullWidth - helperText={t('serverModal.rconPasswordHelper')} - slotProps={{ - htmlInput: { 'data-testid': 'server-password-input' }, - }} - InputProps={{ - endAdornment: ( - - setShowPassword(!showPassword)} - edge="end" - > - {showPassword ? : } - - - ), - }} - /> + { + setPassword(e.target.value); + if (error) setError(''); // Clear error when user starts typing + }} + placeholder={t('serverModal.rconPasswordPlaceholder')} + type={showPassword ? 'text' : 'password'} + required + fullWidth + helperText={t('serverModal.rconPasswordHelper')} + slotProps={{ + htmlInput: { 'data-testid': 'server-password-input' }, + }} + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} + edge="end" + > + {showPassword ? : } + + + ), + }} + /> )} - setSkins(e.target.checked)} - inputProps={{ 'aria-label': t('serverModal.skinsLabel') }} - data-testid="server-skins" - /> - } - label={ - - - {t('serverModal.skinsLabel')} - - - {t('serverModal.skinsHelper')} - - - } - /> {skins && server?.steamToken === true && ( {t('serverModal.skinsTokenWarning')} @@ -507,8 +504,8 @@ export default function ServerModal({ open, server, servers, onClose, onSave }: {saving ? t('serverModal.buttons.saving') : isEditing - ? t('serverModal.buttons.saveChanges') - : t('serverModal.buttons.addServer')} + ? t('serverModal.buttons.saveChanges') + : t('serverModal.buttons.addServer')} diff --git a/client/src/integrations/cs2/setup/Cs2MatchSettings.tsx b/client/src/integrations/cs2/setup/Cs2MatchSettings.tsx index 9ffcf3859..18791173d 100644 --- a/client/src/integrations/cs2/setup/Cs2MatchSettings.tsx +++ b/client/src/integrations/cs2/setup/Cs2MatchSettings.tsx @@ -91,6 +91,26 @@ export function Cs2MatchSettings({ {t('tournament.botFill.help')} + + update({ esportsMode: checked })} + slotProps={{ + input: { + 'data-testid': 'tournament-esports-mode', + } as InputHTMLAttributes, + }} + /> + } + /> + + {t('tournament.esportsMode.help')} + + { if (next.mapSequence) stored.mapSequence = next.mapSequence; if (next.mapPoolId !== undefined) stored.mapPoolId = next.mapPoolId; stored.fillTeamsWithBots = next.fillTeamsWithBots === true; + stored.esportsMode = next.esportsMode === true; 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; @@ -182,7 +186,9 @@ function rulesLine(cs2: Cs2TournamentSettings, ctx: TournamentSetupContext, t: T maxRounds: cs2.maxRounds, winRounds: Math.floor(cs2.maxRounds / 2) + 1, }); - const fill = cs2.fillTeamsWithBots === true ? ` · ${t('tournament.botFill.label')}` : ''; + const fill = + (cs2.fillTeamsWithBots === true ? ` · ${t('tournament.botFill.label')}` : '') + + (cs2.esportsMode === true ? ` · ${t('tournament.esportsMode.label')}` : ''); return `${rounds} · ${overtime}${fill}`; } @@ -290,7 +296,8 @@ export const cs2TournamentSetup: TournamentSetupModel = { old.maxRounds !== next.maxRounds || old.overtimeMode !== next.overtimeMode || old.overtimeSegments !== next.overtimeSegments || - (old.fillTeamsWithBots === true) !== (next.fillTeamsWithBots === true) + (old.fillTeamsWithBots === true) !== (next.fillTeamsWithBots === true) || + (old.esportsMode === true) !== (next.esportsMode === true) ) { changes.push({ field: 'matchRules', From 236422353370d0362085e9129f6119a5764a48ab Mon Sep 17 00:00:00 2001 From: Sivert Date: Mon, 12 Oct 2026 22:34:59 +0200 Subject: [PATCH 2/2] fix(skins): a server's hello only turns its skins plugin on Turning it off is the admin switching skins off (syncSkinsPluginEverywhere), never a side effect of a server connecting. Co-Authored-By: Claude Opus 5.5 --- api/src/integrations/cs2/fleet/push/controls.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/api/src/integrations/cs2/fleet/push/controls.ts b/api/src/integrations/cs2/fleet/push/controls.ts index 554985bc3..b745ed222 100644 --- a/api/src/integrations/cs2/fleet/push/controls.ts +++ b/api/src/integrations/cs2/fleet/push/controls.ts @@ -334,12 +334,15 @@ async function platformSkinsOn(): Promise { */ async function skinsFollowPlatform( serverId: string, - state: { installed: string[]; disabled: string[] } + state: { installed: string[]; disabled: string[] }, + mayTurnOff = false ): Promise { if (!state.installed.includes('skins')) return false; const on = await platformSkinsOn(); const isOff = state.disabled.includes('skins'); if (on === !isOff) return false; + // A hello only turns the plugin on; off is the admin switching skins off. + if (!on && !mayTurnOff) return false; await sendCmd(serverId, { name: 'plugins.set', args: on ? { enable: ['skins'] } : { disable: ['skins'] }, @@ -374,7 +377,11 @@ export async function syncSkinsPluginEverywhere(): Promise { ? (JSON.parse(row.plugins_state) as { installed?: string[]; disabled?: string[] }) : null; if (state && Array.isArray(state.installed) && Array.isArray(state.disabled)) { - await skinsFollowPlatform(row.id, { installed: state.installed, disabled: state.disabled }); + await skinsFollowPlatform( + row.id, + { installed: state.installed, disabled: state.disabled }, + true + ); } } catch (error) { log.warn(`[FLEET] ${row.id}: skins plugin not synced`, { error: (error as Error).message });