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
19 changes: 18 additions & 1 deletion components/draft-games/CreateDraftGame.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ const { result: gameModesResult } = useQuery(
description: true,
enabled: true,
competitive_safe: true,
players_per_team: true,
allow_short_handed_start: true,
supported_runtimes: [{}, true],
},
],
Expand Down Expand Up @@ -450,7 +452,22 @@ const PER_TEAM: Record<string, number> = {
Premier: 5,
Faceit: 5,
};
const perTeam = computed(() => PER_TEAM[matchType.value] || 5);

const selectedGameMode = computed<Record<string, any> | undefined>(() =>
draftEligibleModes.value.find(
(gameMode) => gameMode.id === form.values.game_mode_id,
),
);

// A custom mode sizes its own teams; the match type is what everything else
// falls back to. Mirrors resolvePlayersPerTeam on the API, which is what the
// lobby is actually created with.
const perTeam = computed(
() =>
selectedGameMode.value?.players_per_team ||
PER_TEAM[matchType.value] ||
5,
);

// A team lobby only seats `perTeam` players a side, so anyone the host benched
// in the roster picker can only reach the match through a substitute slot.
Expand Down
19 changes: 18 additions & 1 deletion components/draft-games/DraftModePicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,24 @@ const select = (id: string) => {
</div>

<div class="min-w-0 flex-1">
<p class="font-medium">{{ mode.name }}</p>
<div class="flex flex-wrap items-center gap-2">
<p class="font-medium">{{ mode.name }}</p>
<!-- Team size and whether the host may start short are the two things
that change what the lobby asks of everyone in it, so they belong
on the card rather than being discovered in the room. -->
<span
v-if="mode.players_per_team"
class="rounded border border-border/60 px-1.5 py-0.5 font-mono text-[0.6rem] uppercase tracking-[0.14em] text-muted-foreground"
>
{{ mode.players_per_team }}v{{ mode.players_per_team }}
</span>
<span
v-if="mode.allow_short_handed_start"
class="rounded border border-[hsl(var(--tac-amber)/0.4)] px-1.5 py-0.5 font-mono text-[0.6rem] uppercase tracking-[0.14em] text-[hsl(var(--tac-amber))]"
>
{{ $t("draft_games.create.mode_short_handed") }}
</span>
</div>
<!-- The description is the whole pitch for picking a mode; it wraps
rather than truncating so a long one is still readable. -->
<p class="text-xs leading-snug text-muted-foreground">
Expand Down
86 changes: 80 additions & 6 deletions components/draft-games/DraftRoom.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ArrowUp,
Swords,
Play,
TriangleAlert,
X,
MessagesSquare,
Inbox,
Expand Down Expand Up @@ -659,6 +660,12 @@ const regionsAvailable = computed(
() => appSettings.availableRegions.length > 0,
);

// Only a custom mode may run a match smaller than its declared size, and only
// when the admin who defined that mode said so.
const shortHandedAllowed = computed(
() => props.room.options?.game_mode?.allow_short_handed_start === true,
);

const startReady = computed(() => {
if (props.room.mode === "Teams") {
// The match lineups are exactly what the lobby assigned now, so starting
Expand All @@ -682,6 +689,29 @@ const startReady = computed(() => {
return accepted.value.length === props.room.capacity;
});

// What a short-handed mode will accept: a real match on both sides, and -- for a
// captains draft, whose pick order is built on capacity / 2 -- an even pool.
const forceReady = computed(() => {
if (!shortHandedAllowed.value || startReady.value) {
return false;
}
if (props.room.mode === "Teams") {
return !!props.room.team_1_id && team1Count.value > 0 && team2Count.value > 0;
}
if (props.room.mode === "Host") {
return team1Count.value > 0 && team2Count.value > 0;
}
if (isManualCaptains.value && !manualCaptainsReady.value) {
return false;
}
if (props.room.mode === "Captains") {
return accepted.value.length >= 2 && accepted.value.length % 2 === 0;
}
return accepted.value.length >= 2;
});

const canStart = computed(() => startReady.value || forceReady.value);

const startHint = computed(() => {
if (props.room.mode === "Teams") {
return props.room.team_1_id
Expand All @@ -694,11 +724,35 @@ const startHint = computed(() => {
if (isManualCaptains.value && !manualCaptainsReady.value) {
return "draft_games.room.pick_captains";
}
if (shortHandedAllowed.value && props.room.mode === "Captains") {
return "draft_games.room.need_even";
}
return "draft_games.room.need_full";
});

// Starting short is a decision the room cannot walk back -- everyone still on
// their way in loses the seat -- so it asks once.
const confirmingForce = ref(false);

// Someone joining while the confirm is armed turns this back into an ordinary
// full start; leaving it armed would keep offering to start short.
watch(forceReady, (short) => {
if (!short) {
confirmingForce.value = false;
}
});

const start = () => {
return runGuarded("start", () => useDraftGamesStore().start(props.room.id));
if (forceReady.value && !confirmingForce.value) {
confirmingForce.value = true;
return;
}

confirmingForce.value = false;

return runGuarded("start", () =>
useDraftGamesStore().start(props.room.id, forceReady.value),
);
};
</script>

Expand Down Expand Up @@ -892,7 +946,7 @@ const start = () => {
</div>

<Button
v-else-if="startReady"
v-else-if="canStart"
key="start"
variant="tactical"
type="button"
Expand All @@ -904,11 +958,21 @@ const start = () => {
@click="start"
>
<Spinner v-if="isPending('start')" class="h-5 w-5" />
<TriangleAlert
v-else-if="confirmingForce"
class="h-5 w-5"
/>
<Play v-else class="h-5 w-5" />
{{
isPending("start")
? $t("draft_games.room.starting")
: $t("draft_games.room.start_match")
: confirmingForce
? $t("draft_games.room.force_start_confirm", {
count: accepted.length,
})
: forceReady
? $t("draft_games.room.force_start")
: $t("draft_games.room.start_match")
}}
</Button>

Expand Down Expand Up @@ -1139,7 +1203,7 @@ const start = () => {
appearing under the cursor mid-assignment. -->
<Transition name="collapse">
<div
v-if="isAssembling || isDrafting || (showStart && startReady)"
v-if="isAssembling || isDrafting || (showStart && canStart)"
class="grid grid-rows-[1fr]"
>
<div class="collapse-clip">
Expand Down Expand Up @@ -1241,7 +1305,7 @@ const start = () => {

<Transition name="cta">
<Button
v-if="showStart && startReady"
v-if="showStart && canStart"
variant="tactical"
type="button"
:disabled="isPending('start') || !regionsAvailable"
Expand All @@ -1252,11 +1316,21 @@ const start = () => {
@click="start"
>
<Spinner v-if="isPending('start')" class="h-5 w-5" />
<TriangleAlert
v-else-if="confirmingForce"
class="h-5 w-5"
/>
<Play v-else class="h-5 w-5" />
{{
isPending("start")
? $t("draft_games.room.starting")
: $t("draft_games.room.start_match")
: confirmingForce
? $t("draft_games.room.force_start_confirm", {
count: accepted.length,
})
: forceReady
? $t("draft_games.room.force_start")
: $t("draft_games.room.start_match")
}}
</Button>
</Transition>
Expand Down
64 changes: 64 additions & 0 deletions components/game-modes/GameModeForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,57 @@ import { SELECT_NONE, nullableSelectField } from "~/utilities/selectNone";
</div>
</div>

<!-- Team size lives on the mode rather than on each lobby: a mode is a set
of plugins and cvars built for a particular shape of match, and a host
picking "Retakes" should not also have to know it is a 3v3. -->
<div class="space-y-3 rounded-md border p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">
{{ $t("game_modes.form.players_per_team") }}
</p>
<p class="text-sm text-muted-foreground">
{{ $t("game_modes.form.players_per_team_description") }}
</p>
</div>
<FormField v-slot="{ componentField }" name="players_per_team">
<FormItem class="w-28 shrink-0">
<FormControl>
<Input
type="number"
min="1"
max="5"
:placeholder="$t('game_modes.form.players_per_team_inherit')"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>

<div class="flex items-start justify-between gap-4 border-t pt-3">
<div>
<p class="font-medium">
{{ $t("game_modes.form.allow_short_handed_start") }}
</p>
<p class="text-sm text-muted-foreground">
{{ $t("game_modes.form.allow_short_handed_start_description") }}
</p>
</div>
<FormField
v-slot="{ value, handleChange }"
name="allow_short_handed_start"
>
<FormItem>
<FormControl>
<Switch :model-value="value" @update:model-value="handleChange" />
</FormControl>
</FormItem>
</FormField>
</div>
</div>

<!-- Compatibility is derived from the plugins below, not declared here: a
mode runs where every plugin in it publishes a build. -->
<div
Expand Down Expand Up @@ -295,6 +346,13 @@ export default {
description: z.string().optional().default(""),
enabled: z.boolean().default(true),
competitive_safe: z.boolean().default(false),
// An empty box means "inherit the match type's count", so it has to
// survive as null rather than coercing to 0.
players_per_team: z
.union([z.coerce.number().int().min(1).max(5), z.literal("")])
.optional()
.default(""),
allow_short_handed_start: z.boolean().default(false),
cfg: z.string().optional().default(""),
extra_game_params: z.string().optional().default(""),
}),
Expand Down Expand Up @@ -365,6 +423,8 @@ export default {
description: mode.description ?? "",
enabled: mode.enabled,
competitive_safe: mode.competitive_safe,
players_per_team: mode.players_per_team ?? "",
allow_short_handed_start: mode.allow_short_handed_start ?? false,
cfg: mode.cfg ?? "",
extra_game_params: mode.extra_game_params ?? "",
});
Expand Down Expand Up @@ -429,6 +489,10 @@ export default {
description: values.description || null,
...(this.gameMode?.archived_at ? {} : { enabled: values.enabled }),
competitive_safe: values.competitive_safe,
players_per_team: values.players_per_team
? Number(values.players_per_team)
: null,
allow_short_handed_start: values.allow_short_handed_start,
cfg: values.cfg || null,
extra_game_params: values.extra_game_params || null,
};
Expand Down
11 changes: 10 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -8705,7 +8705,8 @@
"step_mode": "Mode",
"mode_description": "How the match plays. Leave it on Competitive for a normal match, or pick a mode to load its plugins on the server. Custom modes never count toward ELO.",
"mode_none": "Competitive",
"mode_none_hint": "Standard rules. Counts toward ELO and leaderboards."
"mode_none_hint": "Standard rules. Counts toward ELO and leaderboards.",
"mode_short_handed": "Can start short"
},
"host": "The host",
"invite": {
Expand Down Expand Up @@ -8819,6 +8820,9 @@
"no_regions_available_description": "No game server regions have servers available. The draft cannot start until a server comes online — contact an administrator.",
"assign_all": "Assign All Players",
"need_full": "Waiting For Players",
"need_even": "Need An Even Number Of Players",
"force_start": "Start Short-Handed",
"force_start_confirm": "Confirm — Start With {count}",
"search_requests": "Search requests",
"sort_elo_high": "Rank: High",
"sort_elo_low": "Rank: Low",
Expand Down Expand Up @@ -9867,6 +9871,11 @@
"enabled_description": "Disabled modes are kept but cannot be picked for a match, server or draft room.",
"competitive_safe": "Offer in draft lobbies",
"competitive_safe_description": "Lobby hosts can pick this mode when creating a draft room. Matches with any custom mode never count toward ELO or the leaderboards.",
"players_per_team": "Players Per Team",
"players_per_team_description": "How many players a side this mode is built for. Leave it empty to use the match type's own count (5v5, 2v2, 1v1).",
"players_per_team_inherit": "Auto",
"allow_short_handed_start": "Allow starting short-handed",
"allow_short_handed_start_description": "Lets a lobby host start before both sides are full. Only the players seated are sent to the match server.",
"plugins": "Plugins",
"plugins_description": "Loaded in the order shown, after anything placed in custom-plugins by hand.",
"no_plugins": "No game plugins in the catalog yet. Sync the registry and install one first.",
Expand Down
2 changes: 2 additions & 0 deletions pages/settings/application/game-modes.vue
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ export default {
enabled: true,
archived_at: true,
competitive_safe: true,
players_per_team: true,
allow_short_handed_start: true,
supported_runtimes: true,
runtime_conflicts: true,
cfg: true,
Expand Down
18 changes: 10 additions & 8 deletions stores/DraftGamesStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ export const useDraftGamesStore = defineStore("draft-games", () => {
game_mode: {
id: true,
name: true,
players_per_team: true,
allow_short_handed_start: true,
},
map_pool: {
id: true,
Expand Down Expand Up @@ -497,19 +499,19 @@ export const useDraftGamesStore = defineStore("draft-games", () => {
},
});

const start = (draftGameId: string) =>
// Goes through the API rather than setting status directly: a short-handed
// start has to narrow the lobby to the players who turned up before the DB
// trigger and the draft pick pattern read its capacity.
const start = (draftGameId: string, force = false) =>
getGraphqlClient().mutate({
mutation: gql`
mutation StartDraftGame($draftGameId: uuid!) {
update_draft_games_by_pk(
pk_columns: { id: $draftGameId }
_set: { status: Filled }
) {
id
mutation StartDraftGame($draftGameId: uuid!, $force: Boolean) {
startDraftGame(draftGameId: $draftGameId, force: $force) {
success
}
}
`,
variables: { draftGameId },
variables: { draftGameId, force },
});

const pick = (draftGameId: string, steamId: string) =>
Expand Down