From ea3e5a95816d105107f83958c0bb79cd583538cf Mon Sep 17 00:00:00 2001 From: Kristians Laukis <7798036+LCrew@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:09:01 +0300 Subject: [PATCH] feat(draft): configure and use a custom mode's team size Follows the api change that lets game_modes carry players_per_team and allow_short_handed_start. The 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. So the two fields are edited in Application Settings -> Game Modes, and the draft wizard's mode step only reports them, as a badge on each mode card. In the room, a mode that allows it unlocks a Start Short-Handed button once both sides hold at least one player, with a confirm step -- starting short cannot be walked back, as everyone still on their way in loses the seat. The confirm disarms itself if someone joins and the lobby fills. start() now goes through the startDraftGame action instead of setting the status directly, because narrowing the lobby has to happen server-side before the trigger and the draft pick pattern read its capacity. With no custom mode selected none of this engages: perTeam falls back to the match type's count and the start gate keeps its original equality. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G8LmkD3i1HyGjbcxAZ5Crz --- components/draft-games/CreateDraftGame.vue | 19 ++++- components/draft-games/DraftModePicker.vue | 19 ++++- components/draft-games/DraftRoom.vue | 86 ++++++++++++++++++++-- components/game-modes/GameModeForm.vue | 64 ++++++++++++++++ i18n/locales/en.json | 11 ++- pages/settings/application/game-modes.vue | 2 + stores/DraftGamesStore.ts | 18 +++-- 7 files changed, 202 insertions(+), 17 deletions(-) diff --git a/components/draft-games/CreateDraftGame.vue b/components/draft-games/CreateDraftGame.vue index 09202b237..7ee8eb7c9 100644 --- a/components/draft-games/CreateDraftGame.vue +++ b/components/draft-games/CreateDraftGame.vue @@ -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], }, ], @@ -450,7 +452,22 @@ const PER_TEAM: Record = { Premier: 5, Faceit: 5, }; -const perTeam = computed(() => PER_TEAM[matchType.value] || 5); + +const selectedGameMode = computed | 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. diff --git a/components/draft-games/DraftModePicker.vue b/components/draft-games/DraftModePicker.vue index f0657f746..c0f10a044 100644 --- a/components/draft-games/DraftModePicker.vue +++ b/components/draft-games/DraftModePicker.vue @@ -64,7 +64,24 @@ const select = (id: string) => {
-

{{ mode.name }}

+
+

{{ mode.name }}

+ + + {{ mode.players_per_team }}v{{ mode.players_per_team }} + + + {{ $t("draft_games.create.mode_short_handed") }} + +

diff --git a/components/draft-games/DraftRoom.vue b/components/draft-games/DraftRoom.vue index ea5c25b8d..10e02b071 100644 --- a/components/draft-games/DraftRoom.vue +++ b/components/draft-games/DraftRoom.vue @@ -5,6 +5,7 @@ import { ArrowUp, Swords, Play, + TriangleAlert, X, MessagesSquare, Inbox, @@ -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 @@ -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 @@ -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), + ); }; @@ -892,7 +946,7 @@ const start = () => {

@@ -1139,7 +1203,7 @@ const start = () => { appearing under the cursor mid-assignment. -->
@@ -1241,7 +1305,7 @@ const start = () => { diff --git a/components/game-modes/GameModeForm.vue b/components/game-modes/GameModeForm.vue index 6d9c9600a..b6fe86941 100644 --- a/components/game-modes/GameModeForm.vue +++ b/components/game-modes/GameModeForm.vue @@ -99,6 +99,57 @@ import { SELECT_NONE, nullableSelectField } from "~/utilities/selectNone";
+ +
+
+
+

+ {{ $t("game_modes.form.players_per_team") }} +

+

+ {{ $t("game_modes.form.players_per_team_description") }} +

+
+ + + + + + + + +
+ +
+
+

+ {{ $t("game_modes.form.allow_short_handed_start") }} +

+

+ {{ $t("game_modes.form.allow_short_handed_start_description") }} +

+
+ + + + + + + +
+
+
{ game_mode: { id: true, name: true, + players_per_team: true, + allow_short_handed_start: true, }, map_pool: { id: true, @@ -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) =>