Skip to content
Open
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
23 changes: 7 additions & 16 deletions src/admin/games/views/html/games.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -21,20 +25,7 @@ export async function GamesPage() {
<Admin activePage="games">
<form action="" method="post">
<div class="admin-panel-set flex flex-col gap-4">
<dl>
<dt>
<label for="whitelistId">Whitelist ID</label>
</dt>
<dd>
<input
type="text"
name="whitelistId"
value={whitelistId ?? ''}
id="whitelistId"
class="col-span-3"
/>
</dd>
</dl>
<WhitelistId gamemode={gamemode} />

<dl>
<dt>
Expand Down
42 changes: 42 additions & 0 deletions src/admin/games/views/html/whitelist-id.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<dl id="whitelist-id">
<dt class="flex flex-row flex-wrap items-center justify-between gap-2">
<label for="whitelistId">
Whitelist ID{' '}
<span class="text-abru-light-35 text-sm font-normal" safe>
{gamemodeDisplayName(gamemode)}
</span>
</label>
<GamemodeTabs
active={gamemode}
fragment
hxTarget="#whitelist-id"
hrefFn={tab => `/admin/games/whitelist-id?gamemode=${tab}`}
/>
</dt>
<dd>
<input type="hidden" name="whitelistGamemode" value={gamemode} />
<input
type="text"
name="whitelistId"
value={whitelistId ?? ''}
id="whitelistId"
class="col-span-3"
/>
</dd>
</dl>
)
}
54 changes: 54 additions & 0 deletions src/admin/player-restrictions/views/html/default-player-skill.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<dl id="default-player-skill">
<dt class="flex flex-row flex-wrap items-center justify-between gap-2">
<span class="text-abru-light-75 font-bold">
Default player skill{' '}
<span class="text-abru-light-35 text-sm font-normal" safe>
{gamemodeDisplayName(gamemode)}
</span>
</span>
<GamemodeTabs
active={gamemode}
fragment
hxTarget="#default-player-skill"
hrefFn={tab => `/admin/player-restrictions/default-player-skill?gamemode=${tab}`}
/>
</dt>
<dd class="flex flex-col">
<input type="hidden" name="defaultPlayerSkillGamemode" value={gamemode} />
<div class="flex flex-row flex-wrap gap-2">
{classes.map(gameClass => (
<GameClassSkillInput
gameClass={gameClass}
name={`defaultPlayerSkill.${gameClass}`}
value={defaultPlayerSkill[gameClass] ?? 1}
step={skillStep}
/>
))}
</div>
<p class="text-abru-light-75 text-sm">
If a player starts a game without skill assigned for them, the game balance system will
use this fallback value.
</p>
</dd>
</dl>
)
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
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 (
<Admin activePage="player-restrictions">
<form action="" method="post" id="playerRestrictionsForm">
<div class="admin-panel-set flex flex-col gap-4">
<RequireEtf2lAccount />
<MinimumTf2InGameHours />
<RequirePlayerVerification />
<PlayerSkillThreshold />
<PlayerSkillThreshold gamemode={gamemode} />
<SkillStep />
<SkillSuggestions />
<DefaultPlayerSkill />
<DefaultPlayerSkill gamemode={gamemode} />

<p>
<SaveButton />
Expand Down Expand Up @@ -100,48 +103,6 @@ async function RequirePlayerVerification() {
)
}

async function PlayerSkillThreshold() {
const playerSkillThreshold = await configuration.get('queue.player_skill_threshold')
const playerSkillThresholdEnabled = playerSkillThreshold !== null

return (
<dl>
<dt class="group flex flex-row gap-2">
<label for="playerSkillThresholdEnabled">Player skill threshold</label>
<input
type="checkbox"
id="playerSkillThresholdEnabled"
name="playerSkillThresholdEnabled"
value="enabled"
checked={playerSkillThresholdEnabled}
/>
<span class="hidden group-has-checked:inline-block">enabled</span>
<span class="group-has-checked:hidden">disabled</span>
</dt>
<dd class="flex flex-col">
<div>
<label for="playerSkillThreshold" class="sr-only">
Player skill threshold value
</label>
<input
type="number"
id="playerSkillThreshold"
name="playerSkillThreshold"
value={playerSkillThreshold?.toString()}
disabled={!playerSkillThresholdEnabled}
data-toggle-disabled-form="#playerRestrictionsForm"
data-toggle-disabled-control="playerSkillThresholdEnabled"
data-toggle-disabled-checked="true"
/>
</div>
<p class="text-abru-light-75 text-sm">
Players will be able to join queue only on classes that meet the given criteria.
</p>
</dd>
</dl>
)
}

async function SkillStep() {
const skillStep = await configuration.get('games.skill_step')
return (
Expand Down Expand Up @@ -190,33 +151,3 @@ async function SkillSuggestions() {
</div>
)
}

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 (
<dl>
<dt>
<span class="text-abru-light-75 font-bold">Default player skill</span>
</dt>
<dd class="flex flex-col">
<div class="flex flex-row flex-wrap gap-2">
{classes.map(gameClass => (
<GameClassSkillInput
gameClass={gameClass}
name={`defaultPlayerSkill.${gameClass}`}
value={defaultPlayerSkill[gameClass] ?? 1}
step={skillStep}
/>
))}
</div>
<p class="text-abru-light-75 text-sm">
If a player starts a game without skill assigned for them, the game balance system will
use this fallback value.
</p>
</dd>
</dl>
)
}
Original file line number Diff line number Diff line change
@@ -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 (
<dl id="player-skill-threshold">
<dt class="group flex flex-row flex-wrap items-center gap-2">
<label for="playerSkillThresholdEnabled">Player skill threshold</label>
<input
type="checkbox"
id="playerSkillThresholdEnabled"
name="playerSkillThresholdEnabled"
value="enabled"
checked={playerSkillThresholdEnabled}
/>
<span class="hidden group-has-checked:inline-block">enabled</span>
<span class="group-has-checked:hidden">disabled</span>
<span class="text-abru-light-35 text-sm font-normal" safe>
{gamemodeDisplayName(gamemode)}
</span>
<span class="grow"></span>
<GamemodeTabs
active={gamemode}
fragment
hxTarget="#player-skill-threshold"
hrefFn={tab => `/admin/player-restrictions/player-skill-threshold?gamemode=${tab}`}
/>
</dt>
<dd class="flex flex-col">
<input type="hidden" name="playerSkillThresholdGamemode" value={gamemode} />
<div>
<label for="playerSkillThreshold" class="sr-only">
Player skill threshold value
</label>
<input
type="number"
id="playerSkillThreshold"
name="playerSkillThreshold"
value={playerSkillThreshold?.toString()}
disabled={!playerSkillThresholdEnabled}
data-toggle-disabled-form="#playerRestrictionsForm"
data-toggle-disabled-control="playerSkillThresholdEnabled"
data-toggle-disabled-checked="true"
/>
</div>
<p class="text-abru-light-75 text-sm">
Players will be able to join queue only on classes that meet the given criteria.
</p>
</dd>
</dl>
)
}
12 changes: 10 additions & 2 deletions src/configuration/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends keyof Configuration>(
key: T,
value: Configuration[T],
actor: SteamId64 | 'bot',
gamemode?: Gamemode,
): Promise<void> {
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 })
Expand Down
59 changes: 59 additions & 0 deletions src/html/components/gamemode-tabs.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
class="border-abru-light-15 bg-abru-dark-25 inline-flex flex-row flex-wrap gap-1 rounded-lg border p-1"
role="tablist"
>
{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 ? (
<button
type="button"
hx-get={props.hrefFn(tab)}
hx-swap="outerHTML"
{...commonAttrs}
safe
>
{gamemodeDisplayName(tab)}
</button>
) : (
<a href={props.hrefFn(tab)} {...commonAttrs} safe>
{gamemodeDisplayName(tab)}
</a>
)
})}
</div>
)
}
Loading
Loading