diff --git a/src/admin/games/views/html/games.page.tsx b/src/admin/games/views/html/games.page.tsx index 8ebfe8b0f..691cf3659 100644 --- a/src/admin/games/views/html/games.page.tsx +++ b/src/admin/games/views/html/games.page.tsx @@ -6,9 +6,13 @@ import { Admin } from '../../../views/html/admin' import { SaveButton } from '../../../views/html/save-button' import { durationUnit } from '../../duration-unit' import { GameServerCommandPreview } from './game-server-command-preview' +import { WhitelistId } from './whitelist-id' +import { defaultGamemode } from '../../../../shared/default-gamemode' +import type { Gamemode } from '../../../../shared/types/gamemode' -export async function GamesPage() { - const whitelistId = await configuration.get('games.whitelist_id') +export async function GamesPage(props?: { gamemode?: Gamemode }) { + const gamemode = props?.gamemode ?? defaultGamemode + const whitelistId = await configuration.get('games.whitelist_id', gamemode) const joinGameServerTimeout = await configuration.get('games.join_gameserver_timeout') const rejoinGameServerTimeout = await configuration.get('games.rejoin_gameserver_timeout') const executeExtraCommands = await configuration.get('games.execute_extra_commands') @@ -21,20 +25,7 @@ export async function GamesPage() {
-
-
- -
-
- -
-
+
diff --git a/src/admin/games/views/html/whitelist-id.tsx b/src/admin/games/views/html/whitelist-id.tsx new file mode 100644 index 000000000..9d84fb593 --- /dev/null +++ b/src/admin/games/views/html/whitelist-id.tsx @@ -0,0 +1,42 @@ +import { configuration } from '../../../../configuration' +import { GamemodeTabs } from '../../../../html/components/gamemode-tabs' +import { gamemodeDisplayName } from '../../../../shared/gamemode-display-name' +import type { Gamemode } from '../../../../shared/types/gamemode' + +/** + * The per-gamemode "Whitelist ID" section of the games form. Swapped in place by + * its gamemode tabs (GET /admin/games/whitelist-id?gamemode=...). + */ +export async function WhitelistId(props: { gamemode: Gamemode }) { + const { gamemode } = props + const whitelistId = await configuration.get('games.whitelist_id', gamemode) + + return ( +
+
+ + `/admin/games/whitelist-id?gamemode=${tab}`} + /> +
+
+ + +
+
+ ) +} diff --git a/src/admin/player-restrictions/views/html/default-player-skill.tsx b/src/admin/player-restrictions/views/html/default-player-skill.tsx new file mode 100644 index 000000000..b2dad607b --- /dev/null +++ b/src/admin/player-restrictions/views/html/default-player-skill.tsx @@ -0,0 +1,54 @@ +import { configuration } from '../../../../configuration' +import { GameClassSkillInput } from '../../../../html/components/game-class-skill-input' +import { GamemodeTabs } from '../../../../html/components/gamemode-tabs' +import { getQueueConfig } from '../../../../queue-auto/configs' +import { gamemodeDisplayName } from '../../../../shared/gamemode-display-name' +import type { Gamemode } from '../../../../shared/types/gamemode' + +/** + * The per-gamemode "Default player skill" section of the player restrictions + * form. Swapped in place by its gamemode tabs + * (GET /admin/player-restrictions/default-player-skill?gamemode=...). + */ +export async function DefaultPlayerSkill(props: { gamemode: Gamemode }) { + const { gamemode } = props + const defaultPlayerSkill = await configuration.get('games.default_player_skill', gamemode) + const skillStep = await configuration.get('games.skill_step') + const classes = getQueueConfig(gamemode).classes.map(({ name }) => name) + + return ( +
+
+ + Default player skill{' '} + + {gamemodeDisplayName(gamemode)} + + + `/admin/player-restrictions/default-player-skill?gamemode=${tab}`} + /> +
+
+ +
+ {classes.map(gameClass => ( + + ))} +
+

+ If a player starts a game without skill assigned for them, the game balance system will + use this fallback value. +

+
+
+ ) +} diff --git a/src/admin/player-restrictions/views/html/player-restrictions.page.tsx b/src/admin/player-restrictions/views/html/player-restrictions.page.tsx index 16c15ee5d..6db06c726 100644 --- a/src/admin/player-restrictions/views/html/player-restrictions.page.tsx +++ b/src/admin/player-restrictions/views/html/player-restrictions.page.tsx @@ -1,11 +1,14 @@ import { configuration } from '../../../../configuration' import { Switch } from '../../../../html/components/switch' -import { queue } from '../../../../queue-auto' import { Admin } from '../../../views/html/admin' import { SaveButton } from '../../../views/html/save-button' -import { GameClassSkillInput } from '../../../../html/components/game-class-skill-input' +import { PlayerSkillThreshold } from './player-skill-threshold' +import { DefaultPlayerSkill } from './default-player-skill' +import { defaultGamemode } from '../../../../shared/default-gamemode' +import type { Gamemode } from '../../../../shared/types/gamemode' -export async function PlayerRestrictionsPage() { +export async function PlayerRestrictionsPage(props?: { gamemode?: Gamemode }) { + const gamemode = props?.gamemode ?? defaultGamemode return ( @@ -13,10 +16,10 @@ export async function PlayerRestrictionsPage() { - + - +

@@ -100,48 +103,6 @@ async function RequirePlayerVerification() { ) } -async function PlayerSkillThreshold() { - const playerSkillThreshold = await configuration.get('queue.player_skill_threshold') - const playerSkillThresholdEnabled = playerSkillThreshold !== null - - return ( -

-
- - - - disabled -
-
-
- - -
-

- Players will be able to join queue only on classes that meet the given criteria. -

-
-
- ) -} - async function SkillStep() { const skillStep = await configuration.get('games.skill_step') return ( @@ -190,33 +151,3 @@ async function SkillSuggestions() {
) } - -async function DefaultPlayerSkill() { - const defaultPlayerSkill = await configuration.get('games.default_player_skill') - const skillStep = await configuration.get('games.skill_step') - const classes = queue.config.classes.map(({ name }) => name) - - return ( -
-
- Default player skill -
-
-
- {classes.map(gameClass => ( - - ))} -
-

- If a player starts a game without skill assigned for them, the game balance system will - use this fallback value. -

-
-
- ) -} diff --git a/src/admin/player-restrictions/views/html/player-skill-threshold.tsx b/src/admin/player-restrictions/views/html/player-skill-threshold.tsx new file mode 100644 index 000000000..d5a1a4826 --- /dev/null +++ b/src/admin/player-restrictions/views/html/player-skill-threshold.tsx @@ -0,0 +1,63 @@ +import { configuration } from '../../../../configuration' +import { GamemodeTabs } from '../../../../html/components/gamemode-tabs' +import { gamemodeDisplayName } from '../../../../shared/gamemode-display-name' +import type { Gamemode } from '../../../../shared/types/gamemode' + +/** + * The per-gamemode "Player skill threshold" section of the player restrictions + * form. Swapped in place by its gamemode tabs + * (GET /admin/player-restrictions/player-skill-threshold?gamemode=...). + */ +export async function PlayerSkillThreshold(props: { gamemode: Gamemode }) { + const { gamemode } = props + const playerSkillThreshold = await configuration.get('queue.player_skill_threshold', gamemode) + const playerSkillThresholdEnabled = playerSkillThreshold !== null + + return ( +
+
+ + + + disabled + + {gamemodeDisplayName(gamemode)} + + + `/admin/player-restrictions/player-skill-threshold?gamemode=${tab}`} + /> +
+
+ +
+ + +
+

+ Players will be able to join queue only on classes that meet the given criteria. +

+
+
+ ) +} diff --git a/src/configuration/set.ts b/src/configuration/set.ts index 9bbb8ded5..93bce1eae 100644 --- a/src/configuration/set.ts +++ b/src/configuration/set.ts @@ -8,15 +8,23 @@ import { events } from '../events' import { get } from './get' import { activityLog } from '../activity-log' import type { SteamId64 } from '../shared/types/steam-id-64' +import type { Gamemode } from '../shared/types/gamemode' +import { resolveStorageKey } from './gamemode-scoped-keys' export async function set( key: T, value: Configuration[T], actor: SteamId64 | 'bot', + gamemode?: Gamemode, ): Promise { configurationSchema.parse({ key, value }) - const oldValue = await get(key) - await collections.configuration.updateOne({ key }, { $set: { value } }, { upsert: true }) + const storageKey = resolveStorageKey(key, gamemode) + const oldValue = await get(key, gamemode) + await collections.configuration.updateOne( + { key: storageKey }, + { $set: { value } }, + { upsert: true }, + ) if (!isEqual(oldValue, value)) { await activityLog.record({ type: 'configuration change', key, actor }) diff --git a/src/html/components/gamemode-tabs.tsx b/src/html/components/gamemode-tabs.tsx new file mode 100644 index 000000000..bef9f20a6 --- /dev/null +++ b/src/html/components/gamemode-tabs.tsx @@ -0,0 +1,59 @@ +import { enabledGamemodes } from '../../shared/enabled-gamemodes' +import { gamemodeDisplayName } from '../../shared/gamemode-display-name' +import type { Gamemode } from '../../shared/types/gamemode' + +/** + * A row of tabs to switch a surface between gamemodes. Renders nothing on + * single-gamemode instances, where there is nothing to switch between. + * + * With `fragment`, the tabs `hx-get` `hrefFn(tab)` and swap the response into + * `hxTarget` without touching the URL — for per-gamemode editors embedded in a + * form (the admin panel). Without it, they are boosted anchors. + */ +export function GamemodeTabs(props: { + active: Gamemode + hrefFn: (tab: Gamemode) => string + hxTarget?: string + fragment?: boolean +}) { + if (enabledGamemodes.length <= 1) { + return <> + } + + return ( +
+ {enabledGamemodes.map(tab => { + const active = tab === props.active + const commonAttrs = { + class: [ + 'rounded-md px-3 py-1.5 text-sm leading-none font-bold whitespace-nowrap', + active ? 'bg-accent text-white' : 'text-abru-light-60 hover:text-white', + ], + role: 'tab', + 'aria-selected': active ? 'true' : 'false', + 'data-umami-event': 'switch-gamemode', + 'data-umami-event-gamemode': tab, + ...(props.hxTarget ? { 'hx-target': props.hxTarget } : {}), + } + return props.fragment ? ( + + ) : ( + + {gamemodeDisplayName(tab)} + + ) + })} +
+ ) +} diff --git a/src/routes/admin/games/index.ts b/src/routes/admin/games/index.ts index 5e393c507..f731ddb59 100644 --- a/src/routes/admin/games/index.ts +++ b/src/routes/admin/games/index.ts @@ -1,5 +1,6 @@ import { PlayerRole } from '../../../database/models/player.model' import { CooldownLevelEntry, GamesPage } from '../../../admin/games/views/html/games.page' +import { WhitelistId } from '../../../admin/games/views/html/whitelist-id' import { z } from 'zod' import { LogsTfUploadMethod } from '../../../shared/types/logs-tf-upload-method' import { requestContext } from '@fastify/request-context' @@ -7,6 +8,8 @@ import { secondsToMilliseconds } from 'date-fns' import { routes } from '../../../utils/routes' import { configuration } from '../../../configuration' import { durationUnit } from '../../../admin/games/duration-unit' +import { Gamemode } from '../../../shared/types/gamemode' +import { defaultGamemode } from '../../../shared/default-gamemode' // A single form field is submitted as a scalar, multiple as an array; normalize both to an array. const formArray = (schema: T) => @@ -29,6 +32,22 @@ export default routes(async app => { await reply.status(200).html(GamesPage()) }, ) + .get( + '/whitelist-id', + { + config: { + authorize: [PlayerRole.admin], + }, + schema: { + querystring: z.object({ + gamemode: z.enum(Gamemode).default(defaultGamemode), + }), + }, + }, + async (request, reply) => { + await reply.status(200).html(WhitelistId({ gamemode: request.query.gamemode })) + }, + ) .post( '/', { @@ -39,6 +58,7 @@ export default routes(async app => { body: z .object({ whitelistId: z.string(), + whitelistGamemode: z.enum(Gamemode).default(defaultGamemode), joinGameserverTimeout: z.coerce.number(), rejoinGameserverTimeout: z.coerce.number(), executeExtraCommands: z.string().transform(value => value.split('\n')), @@ -59,7 +79,12 @@ export default routes(async app => { banLengthMs: durationUnit.toMs(value, request.body['banLengthUnit[]'][i]!), })) await Promise.all([ - configuration.set('games.whitelist_id', request.body.whitelistId, actor), + configuration.set( + 'games.whitelist_id', + request.body.whitelistId, + actor, + request.body.whitelistGamemode, + ), configuration.set( 'games.join_gameserver_timeout', secondsToMilliseconds(request.body.joinGameserverTimeout), diff --git a/src/routes/admin/player-restrictions/index.ts b/src/routes/admin/player-restrictions/index.ts index 0509ff2ab..88d23d240 100644 --- a/src/routes/admin/player-restrictions/index.ts +++ b/src/routes/admin/player-restrictions/index.ts @@ -1,11 +1,15 @@ import { PlayerRole } from '../../../database/models/player.model' import { PlayerRestrictionsPage } from '../../../admin/player-restrictions/views/html/player-restrictions.page' +import { PlayerSkillThreshold } from '../../../admin/player-restrictions/views/html/player-skill-threshold' +import { DefaultPlayerSkill } from '../../../admin/player-restrictions/views/html/default-player-skill' import { z } from 'zod' import { configuration } from '../../../configuration' import { requestContext } from '@fastify/request-context' import { routes } from '../../../utils/routes' -import { queue } from '../../../queue-auto' -import type { Tf2ClassName } from '../../../shared/types/tf2-class-name' +import { getQueueConfig } from '../../../queue-auto/configs' +import { Gamemode } from '../../../shared/types/gamemode' +import { defaultGamemode } from '../../../shared/default-gamemode' +import { Tf2ClassName } from '../../../shared/types/tf2-class-name' const playerSkillThresholdSchema = z.discriminatedUnion('playerSkillThresholdEnabled', [ z.object({ @@ -31,6 +35,38 @@ export default routes(async app => { await reply.status(200).html(PlayerRestrictionsPage()) }, ) + .get( + '/player-skill-threshold', + { + config: { + authorize: [PlayerRole.admin], + }, + schema: { + querystring: z.object({ + gamemode: z.enum(Gamemode).default(defaultGamemode), + }), + }, + }, + async (request, reply) => { + await reply.status(200).html(PlayerSkillThreshold({ gamemode: request.query.gamemode })) + }, + ) + .get( + '/default-player-skill', + { + config: { + authorize: [PlayerRole.admin], + }, + schema: { + querystring: z.object({ + gamemode: z.enum(Gamemode).default(defaultGamemode), + }), + }, + }, + async (request, reply) => { + await reply.status(200).html(DefaultPlayerSkill({ gamemode: request.query.gamemode })) + }, + ) .post( '/', { @@ -46,12 +82,17 @@ export default routes(async app => { requirePlayerVerification: z.coerce.boolean().default(false), skillSuggestions: z.coerce.boolean().default(false), skillStep: z.coerce.number().positive(), - ...queue.config.classes - .map(({ name }) => name) - .reduce>>( - (acc, key) => ({ ...acc, [`defaultPlayerSkill.${key}`]: z.coerce.number() }), - {}, - ), + playerSkillThresholdGamemode: z.enum(Gamemode).default(defaultGamemode), + defaultPlayerSkillGamemode: z.enum(Gamemode).default(defaultGamemode), + ...Object.values(Tf2ClassName).reduce< + Partial>> + >( + (acc, key) => ({ + ...acc, + [`defaultPlayerSkill.${key}`]: z.coerce.number().optional(), + }), + {}, + ), }), ), }, @@ -64,11 +105,19 @@ export default routes(async app => { playerSkillThresholdEnabled, skillSuggestions, skillStep, + playerSkillThresholdGamemode, + defaultPlayerSkillGamemode, } = request.body + + const classes = new Set( + getQueueConfig(defaultPlayerSkillGamemode).classes.map(({ name }) => name), + ) const defaultPlayerSkill = Object.entries(request.body) .filter(([key]) => key.startsWith('defaultPlayerSkill.')) + .map(([key, value]) => [key.split('.')[1]!, value] as const) + .filter(([className, value]) => classes.has(className) && typeof value === 'number') .reduce>>( - (acc, [key, value]) => ({ ...acc, [key.split('.')[1] as Tf2ClassName]: value }), + (acc, [className, value]) => ({ ...acc, [className as Tf2ClassName]: value as number }), {}, ) @@ -81,8 +130,14 @@ export default routes(async app => { 'queue.player_skill_threshold', playerSkillThresholdEnabled ? request.body.playerSkillThreshold : null, actor, + playerSkillThresholdGamemode, + ), + configuration.set( + 'games.default_player_skill', + defaultPlayerSkill, + actor, + defaultPlayerSkillGamemode, ), - configuration.set('games.default_player_skill', defaultPlayerSkill, actor), configuration.set('games.skill_step', skillStep, actor), configuration.set('games.skill_suggestions', skillSuggestions, actor), ])