diff --git a/components/match/MatchActions.vue b/components/match/MatchActions.vue index 34ac569fc..d49e33038 100644 --- a/components/match/MatchActions.vue +++ b/components/match/MatchActions.vue @@ -1,20 +1,20 @@ - - + + + + + + {{ startLabel }} + + + + {{ $t("match.commands.force_ready") }} + + + + {{ isPaused ? $t("match.actions.resume") : $t("match.actions.pause") }} + + + + {{ $t("match.winner.set") }} + + + + + + {{ $t("match.actions.cancel") }} + + + + + + {{ $t("match.actions.delete") }} + + + + + - + + + + {{ $t("match.admin_bar.actions") }} + + + {{ $t("common.more") }} - - + + - - + + + {{ $t("match.admin_bar.server") }} + + {{ serverLabel }} + - - + + {{ $t("match.admin_bar.winner") }} + + {{ winnerLabel }} + - + + + + + {{ $t("match.admin_bar.coming_up") }} + + + {{ $t(action.label) }} + + {{ $t(`match.admin_bar.steps.${action.step}`) }} + + + - + + !open && !confirmBusy && (confirmOpen = false)" + > - {{ - $t("match.delete_confirm.title") - }} + {{ confirming?.title }} - {{ $t("match.delete_confirm.description") }} + {{ confirming?.description }} + (confirmChoice = choice)" + /> + (confirmChoice = choice)" + /> - + {{ $t("common.cancel") }} - + - {{ $t("common.delete") }} - + {{ confirming?.action }} + @@ -386,9 +508,26 @@ import { useApplicationSettingsStore } from "~/stores/ApplicationSettings"; import { canWatchMatchCameras } from "~/composables/useMatchCameraStatus"; import { RconAction, + matchCommandsForStatus, resolveRconCommand, effectivePluginRuntime, } from "~/constants/rconCommands"; +import { + currentMatchAdminStep, + upcomingAdminActions, +} from "~/utilities/matchAdminSteps"; +type MatchActionChoice = { value: string; label: string }; + +type MatchActionConfirm = { + title: string; + description: string; + action: string; + destructive?: boolean; + // Server and winner dialogs carry their picker; the action waits for a pick. + picker?: "server" | "winner"; + run: (choice: MatchActionChoice | null) => Promise; +}; + export default { inject: { refetchMatchStatic: { @@ -400,11 +539,22 @@ export default { type: Object, required: true, }, + // The admin dock's copy: organizer items only, behind a labeled chip that + // opens upward. The camera chip, pause button and support call stay in + // the header. + dock: { + type: Boolean, + default: false, + }, }, data() { return { - showDeleteDialog: false, + confirmOpen: false, + confirmBusy: false, + confirming: null as MatchActionConfirm | null, + confirmChoice: null as MatchActionChoice | null, rconUuid: undefined as string | undefined, + rconSuccessTitle: "", switching: false, renderSummary: [] as Array<{ match_map_id: string; @@ -438,6 +588,127 @@ export default { }, }, methods: { + ask(confirming: MatchActionConfirm) { + this.confirming = confirming; + this.confirmChoice = null; + this.confirmOpen = true; + }, + async runConfirm() { + if (this.confirmBusy || !this.confirming) { + return; + } + this.confirmBusy = true; + try { + await this.confirming.run(this.confirmChoice); + this.confirmOpen = false; + } finally { + this.confirmBusy = false; + } + }, + confirmCancel() { + this.ask({ + title: this.$t("match.actions.cancel"), + description: this.$t("match.cancel_confirm.description"), + action: this.$t("match.actions.cancel"), + destructive: true, + run: this.cancelMatch, + }); + }, + confirmDelete() { + this.ask({ + title: this.$t("match.delete_confirm.title"), + description: this.$t("match.delete_confirm.description"), + action: this.$t("common.delete"), + destructive: true, + run: this.deleteMatch, + }); + }, + openServerDialog() { + this.ask({ + title: this.$t("match.server.assign"), + description: this.$t("match.server.assign_hint"), + action: this.$t("match.server.assign"), + picker: "server", + run: async (choice) => { + if (choice) { + await this.assignServer(choice.value); + } + }, + }); + }, + openWinnerDialog() { + this.ask({ + title: this.$t("match.winner.set"), + description: this.$t("match.winner.set_hint"), + action: this.$t("match.winner.set"), + picker: "winner", + run: async (choice) => { + if (choice) { + await this.setWinner(choice.value); + } + }, + }); + }, + // "0:" is an on-demand server in that region; anything else is a + // dedicated server id. + async assignServer(value: string) { + const [serverId, region] = value.split(":"); + + await this.$apollo.mutate({ + mutation: generateMutation({ + update_matches_by_pk: [ + { + pk_columns: { + id: this.match.id, + }, + _set: { + region, + server_id: serverId === "0" ? null : value, + }, + }, + { + id: true, + }, + ], + }), + }); + + toast({ + title: this.$t("match.server.assigned"), + }); + }, + // Goes through setMatchWinner rather than writing winning_lineup_id + // directly: the action is what enforces organizer permission and blocks a + // reassignment once a downstream tournament match has already been + // played. A direct mutation skips both. + async setWinner(lineupId: string) { + try { + await this.$apollo.mutate({ + mutation: generateMutation({ + setMatchWinner: [ + { + match_id: this.match.id, + winning_lineup_id: lineupId, + }, + { + success: true, + }, + ], + }), + }); + } catch (error) { + toast({ + title: this.$t("match.winner.set_failed"), + description: (error as Error)?.message, + variant: "destructive", + }); + return; + } + + toast({ + title: this.$t("match.winner.set"), + }); + }, async cancelMatch() { if (this.cancellingMatch) { return; @@ -720,29 +991,38 @@ export default { title: this.$t("common.error"), }); } else { - toast({ - title: this.isPaused - ? this.$t("match.actions.match_resumed") - : this.$t("match.actions.match_paused"), - }); + toast({ title: this.rconSuccessTitle }); } }, - togglePause() { + // The success title is fixed at send time: by the time the reply lands, + // the map status may already have flipped. + sendRcon(action: RconAction, successTitle: string) { const pluginRuntime = effectivePluginRuntime( this.match.server_plugin_runtime, useApplicationSettingsStore().gameServerPluginRuntime, ); - const command = resolveRconCommand( - this.isPaused ? RconAction.Resume : RconAction.Pause, - pluginRuntime, - ); this.rconUuid = uuidv4(); + this.rconSuccessTitle = successTitle; socket.event("rcon", { uuid: this.rconUuid, serverId: this.match.server_id, - command, + command: resolveRconCommand(action, pluginRuntime), }); }, + togglePause() { + this.sendRcon( + this.isPaused ? RconAction.Resume : RconAction.Pause, + this.isPaused + ? this.$t("match.actions.match_resumed") + : this.$t("match.actions.match_paused"), + ); + }, + forceReady() { + this.sendRcon( + RconAction.ForceReady, + this.$t("match.actions.forced_ready"), + ); + }, async callForOrganizer() { await this.$apollo.mutate({ mutation: generateMutation({ @@ -783,6 +1063,79 @@ export default { hasOrganizerLiveActions() { return this.isLive || this.hasMatchDemos; }, + // The dock's one main button for the current step. The veto step's, + // Veto Override, is rendered by the admin bar itself. + primaryAction() { + if (!this.dock) { + return null; + } + switch (currentMatchAdminStep(this.match)) { + case "check_in": + return this.match.can_start ? "start" : null; + case "warmup": + return this.canForceReady ? "force_ready" : null; + case "live": + return this.canPauseResume ? "pause" : null; + case "finished": + return this.canSetMatchWinner ? "winner" : null; + } + return null; + }, + serverLabel() { + if (this.match.server_id && this.match.server_type !== "On Demand") { + return this.$t("match.admin_bar.dedicated"); + } + return ( + this.match.e_region?.description || + this.match.region || + this.$t("match.admin_bar.not_set") + ); + }, + winnerLabel() { + const winner = [this.match.lineup_1, this.match.lineup_2].find( + (lineup) => lineup?.id && lineup.id === this.match.winning_lineup_id, + ); + return winner?.name ?? this.$t("match.admin_bar.not_set"); + }, + startLabel() { + return this.match.options?.map_veto && + this.match.options.best_of != this.match.match_maps?.length + ? this.$t("match.actions.start_veto") + : this.$t("match.actions.skip_checkin"); + }, + showStartItem() { + return this.match.can_start && this.primaryAction !== "start"; + }, + showForceReadyItem() { + return this.canForceReady && this.primaryAction !== "force_ready"; + }, + upcoming() { + return this.dock + ? upcomingAdminActions(this.match, (role) => + useAuthStore().isRoleAbove(role), + ) + : []; + }, + // Everything the dock's More actions menu would list. + hasMoreItems() { + return ( + this.match.can_assign_server || + (this.canSetMatchWinner && this.primaryAction !== "winner") || + (this.match.is_organizer && this.hasOrganizerLiveActions) || + this.canReparseDemos || + this.showForceReadyItem || + this.showStartItem || + this.upcoming.length > 0 + ); + }, + hasDockItems() { + return ( + !!this.primaryAction || + this.match.can_cancel || + this.canDeleteMatch || + this.hasMoreItems + ); + }, activeStreamElsewhere() { const streams = useStreamerStore().liveStreams ?? []; return streams.find( @@ -930,6 +1283,19 @@ export default { isPaused() { return this.currentMap?.status === e_match_map_status_enum.Paused; }, + // Same rule as the console's Force Ready, narrowed to when it can act: the + // current map is warming up on a live, reachable server. + canForceReady() { + return ( + this.match.is_organizer && + this.isLive && + !!this.match.is_server_online && + useAuthStore().isRoleAbove(e_player_roles_enum.moderator) && + matchCommandsForStatus(this.currentMap?.status).some( + (command) => command.action === RconAction.ForceReady, + ) + ); + }, canPauseResume() { if (!this.match.is_organizer) { return false; diff --git a/components/match/MatchAdminBottomBar.vue b/components/match/MatchAdminBottomBar.vue index a446c3826..124245a5a 100644 --- a/components/match/MatchAdminBottomBar.vue +++ b/components/match/MatchAdminBottomBar.vue @@ -10,6 +10,8 @@ import { Zap, } from "lucide-vue-next"; import { useVetoOverride } from "~/composables/useVetoOverride"; +import MatchActions from "~/components/match/MatchActions.vue"; +import MatchAdminSteps from "~/components/match/MatchAdminSteps.vue"; import MatchServerRebootControl from "~/components/match/MatchServerRebootControl.vue"; import RconCommander from "~/components/servers/RconCommander.vue"; import ServiceLogs from "~/components/ServiceLogs.vue"; @@ -241,15 +243,14 @@ function runCommand( /> - + - - {{ $t("match.admin") }} + {{ $t("match.admin") }} - - {{ - match.is_server_online - ? $t("common.connected") - : $t("common.disconnected") - }} - - + + {{ $t("match.admin_bar.veto_override") }} + + - {{ - open ? $t("common.close") : $t("common.more") - }} +import { computed } from "vue"; +import { Check } from "lucide-vue-next"; +import FiveStackToolTip from "~/components/FiveStackToolTip.vue"; +import { useAuthStore } from "~/stores/AuthStore"; +import { e_match_map_status_enum, e_match_status_enum } from "~/generated/zeus"; +import { + type MatchAdminStep, + currentMatchAdminStep, + matchAdminSteps, + upcomingAdminActions, +} from "~/utilities/matchAdminSteps"; + +const props = defineProps<{ + match: Record; +}>(); + +const steps = computed(() => matchAdminSteps(props.match)); +const currentIndex = computed(() => + steps.value.indexOf(currentMatchAdminStep(props.match)), +); + +const upcoming = computed(() => + upcomingAdminActions(props.match, (role) => useAuthStore().isRoleAbove(role)), +); + +function labelKey(step: MatchAdminStep) { + if ( + step === "live" && + props.match.match_maps?.find((map: any) => map.is_current_map)?.status === + e_match_map_status_enum.Paused + ) { + return "match.admin_bar.steps.paused"; + } + if ( + step === "finished" && + props.match.status === e_match_status_enum.Canceled + ) { + return "match.admin_bar.steps.canceled"; + } + return `match.admin_bar.steps.${step}`; +} + +function unlocksAt(step: MatchAdminStep) { + return upcoming.value.filter((action) => action.step === step); +} + + + + + + + + + + + + + + {{ $t(labelKey(step)) }} + + + + + + {{ $t("match.admin_bar.steps.done") }} + + + {{ $t("match.admin_bar.steps.now") }} + + + + {{ $t("match.admin_bar.steps.unlocks") }} + + + {{ $t(action.label) }} + + + + {{ $t("match.admin_bar.steps.nothing_new") }} + + + + + diff --git a/components/match/MatchSelectServer.vue b/components/match/MatchSelectServer.vue index 483d6e692..22546a1fc 100644 --- a/components/match/MatchSelectServer.vue +++ b/components/match/MatchSelectServer.vue @@ -13,17 +13,15 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select"; -import SettingHeader from "~/components/match/SettingHeader.vue"; - {{ $t("match.server.assign") }} - + - + @@ -42,11 +40,11 @@ import SettingHeader from "~/components/match/SettingHeader.vue"; {{ $t("match.server.on_demand") }} - {{ region.description || region.value }} + {{ region.display }} @@ -62,8 +60,6 @@ import * as z from "zod"; import { useForm } from "vee-validate"; import { toTypedSchema } from "~/utilities/vee-validate-zod"; import { typedGql } from "~/generated/zeus/typedDocumentNode"; -import { generateMutation } from "~/graphql/graphqlGen"; -import { toast } from "@/components/ui/toast"; import { useApplicationSettingsStore } from "~/stores/ApplicationSettings"; import { e_server_types_enum } from "~/generated/zeus"; @@ -74,6 +70,8 @@ export default { required: true, }, }, + // The parent's dialog assigns the server; this only reports the pick. + emits: ["select"], apollo: { $subscribe: { servers: { @@ -114,54 +112,43 @@ export default { }; }, watch: { - match: { + // Only a real server change resets the pick; the match object itself is + // replaced on every live update. + serverKey: { immediate: true, - handler(match) { - if (match) { - let server_id = match.server_id; - - if (!server_id || match.server_type === "On Demand") { - server_id = `${match.region ? `0:${match.region}` : "0"}`; - } - - this.form.setValues({ - server_id, - }); - } + handler() { + this.syncFromMatch(); }, }, }, methods: { - async updateMatchServer() { - const [serverId, region] = this.form.values.server_id?.split(":"); + syncFromMatch() { + let server_id = this.match.server_id; - await this.$apollo.mutate({ - mutation: generateMutation({ - update_matches_by_pk: [ - { - pk_columns: { - id: this.match.id, - }, - _set: { - region, - server_id: serverId === "0" ? null : this.form.values.server_id, - }, - }, - { - id: true, - }, - ], - }), - }); + if (!server_id || this.match.server_type === "On Demand") { + server_id = `${this.match.region ? `0:${this.match.region}` : "0"}`; + } - toast({ - title: this.$t("match.server.assigned"), + this.form.setValues({ + server_id, }); }, + pick(value: string) { + const option = [...this.availableServers, ...this.regionOptions].find( + (option) => option.value === value, + ); + this.$emit("select", { value, label: option?.display ?? value }); + }, }, computed: { - regions() { - return useApplicationSettingsStore().availableRegions; + serverKey() { + return `${this.match.server_id}:${this.match.server_type}:${this.match.region}`; + }, + regionOptions() { + return useApplicationSettingsStore().availableRegions.map((region) => ({ + value: `0:${region.value}`, + display: region.description || region.value, + })); }, canSelectDedicatedServer() { const { isAdmin, isMatchOrganizer, isTournamentOrganizer } = diff --git a/components/match/MatchSelectWinner.vue b/components/match/MatchSelectWinner.vue index b375a54dc..39599d9d1 100644 --- a/components/match/MatchSelectWinner.vue +++ b/components/match/MatchSelectWinner.vue @@ -1,57 +1,37 @@ - - - - - - {{ $t("match.winner.set") }} - - - - - - - - - - {{ lineup.display }} - - - - - - - - + + + + {{ lineup.display }} + + {{ $t("match.admin_bar.current") }} + + +