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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/src/integrations/cs2/fleet/assignConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
92 changes: 86 additions & 6 deletions api/src/integrations/cs2/fleet/push/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};
if (value.enable.length > 0) args.enable = value.enable;
if (value.disable.length > 0) args.disable = value.disable;
Expand Down Expand Up @@ -216,7 +216,8 @@ async function linkCreatedOnce(serverId: string): Promise<void> {
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;
Expand Down Expand Up @@ -250,10 +251,15 @@ async function linkKeyEnrolledOnce(serverId: string): Promise<void> {
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`);
}

Expand All @@ -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`
Expand All @@ -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
Expand All @@ -308,3 +320,71 @@ export async function pluginsOnHello(
}
return 'resync';
}

/** The platform's skins are on (Skins settings). */
async function platformSkinsOn(): Promise<boolean> {
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[] },
mayTurnOff = false
): Promise<boolean> {
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'] },
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<void> {
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 },
true
);
}
} catch (error) {
log.warn(`[FLEET] ${row.id}: skins plugin not synced`, { error: (error as Error).message });
}
}
}
27 changes: 23 additions & 4 deletions api/src/integrations/cs2/fleet/push/pluginSets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,29 @@ export async function runsFleetCmds(fleetServerId: string): Promise<boolean> {
}
}

/** 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). */
Expand Down
40 changes: 24 additions & 16 deletions api/src/integrations/cs2/fleet/push/skins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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[];
Expand All @@ -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<void> {
export async function pushSkinsOnConnect(
fleetServerId: string,
matchSlug: string,
steamid64: string
): Promise<void> {
try {
const target = await skinsTarget(fleetServerId);
if (!target.ok) return;
Expand All @@ -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,
});
}
}
2 changes: 2 additions & 0 deletions api/src/integrations/cs2/matchConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
};
Expand Down
52 changes: 42 additions & 10 deletions api/src/integrations/cs2/skins/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` });
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 });
Expand All @@ -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');
Expand Down Expand Up @@ -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<SkinsConfig>);
// 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');
Expand All @@ -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<string>();
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));
Expand Down Expand Up @@ -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) {
Expand All @@ -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');
}
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading