From 43c9bf1af5a71be030fb1a450136efd5a6884ae0 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 2 Sep 2026 13:36:11 -0400 Subject: [PATCH] bug: fix showing json in tournament forms --- components/tournament/TournamentDetail.vue | 7 +- components/tournament/TournamentEntryGate.vue | 18 +- .../tournament/TournamentFreeAgentSignUp.vue | 253 +++++++++++ .../tournament/TournamentFreeAgents.vue | 291 +++--------- components/tournament/TournamentJoinForm.vue | 418 +++++++++++------- i18n/locales/ar_SA.json | 7 + i18n/locales/da_DK.json | 7 + i18n/locales/de_DE.json | 7 + i18n/locales/en.json | 7 + i18n/locales/es_ES.json | 7 + i18n/locales/fr_FR.json | 7 + i18n/locales/it_IT.json | 7 + i18n/locales/ja_JP.json | 7 + i18n/locales/ko_KR.json | 7 + i18n/locales/pl_PL.json | 7 + i18n/locales/pt_BR.json | 7 + i18n/locales/ru_RU.json | 7 + i18n/locales/sv_SE.json | 7 + i18n/locales/tr_TR.json | 7 + i18n/locales/uk_UA.json | 7 + i18n/locales/zh_Hans.json | 7 + i18n/locales/zh_Hant.json | 7 + utilities/tournamentActions.ts | 27 ++ utilities/tournamentElo.ts | 41 ++ 24 files changed, 772 insertions(+), 402 deletions(-) create mode 100644 components/tournament/TournamentFreeAgentSignUp.vue create mode 100644 utilities/tournamentActions.ts create mode 100644 utilities/tournamentElo.ts diff --git a/components/tournament/TournamentDetail.vue b/components/tournament/TournamentDetail.vue index 0b39d30d..a345780c 100644 --- a/components/tournament/TournamentDetail.vue +++ b/components/tournament/TournamentDetail.vue @@ -1016,7 +1016,10 @@ function clearTeamEnterDelay(el: Element) { {{ $t("tournament.join.title") }} - + + {{ $t("tournament.join.requirements", { count: tournament.min_players_per_lineup, @@ -1028,6 +1031,8 @@ function clearTeamEnterDelay(el: Element) {
diff --git a/components/tournament/TournamentEntryGate.vue b/components/tournament/TournamentEntryGate.vue index 7f44158f..c46b8b83 100644 --- a/components/tournament/TournamentEntryGate.vue +++ b/components/tournament/TournamentEntryGate.vue @@ -5,6 +5,7 @@ import { ShieldCheck, ShieldX } from "lucide-vue-next"; import TournamentChip from "~/components/tournament/TournamentChip.vue"; import { e_tournament_status_enum } from "~/generated/zeus"; import { useAuthStore } from "~/stores/AuthStore"; +import { tournamentPlayerElo } from "~/utilities/tournamentElo"; const props = defineProps<{ tournament: Record; @@ -47,17 +48,12 @@ const roleBlocked = computed( () => !!minRole.value && props.registration?.meets_min_role === false, ); -// get_tournament_player_elo reads the WINGMAN ladder when the lineup minimum is -// 2 and the Competitive one otherwise, while `players.elo` is only ever the -// latter. Rather than announce a verdict off the wrong ladder, a 2v2 tournament -// gets the requirement without the comparison. -const myElo = computed(() => { - if (Number(props.tournament?.min_players_per_lineup) === 2) { - return null; - } - const elo = Number(me.value?.elo); - return Number.isFinite(elo) ? elo : null; -}); +// The verdict has to come off the ladder get_tournament_player_elo gates on -- +// Wingman when the lineup minimum is 2, Competitive otherwise -- or the panel +// announces a pass the insert trigger is about to refuse. +const myElo = computed(() => + tournamentPlayerElo(props.tournament, me.value), +); const eloBlocked = computed(() => { if (myElo.value === null) { diff --git a/components/tournament/TournamentFreeAgentSignUp.vue b/components/tournament/TournamentFreeAgentSignUp.vue new file mode 100644 index 00000000..db530d0f --- /dev/null +++ b/components/tournament/TournamentFreeAgentSignUp.vue @@ -0,0 +1,253 @@ + + + diff --git a/components/tournament/TournamentFreeAgents.vue b/components/tournament/TournamentFreeAgents.vue index 583a93be..41fd766e 100644 --- a/components/tournament/TournamentFreeAgents.vue +++ b/components/tournament/TournamentFreeAgents.vue @@ -2,7 +2,7 @@ import { computed, ref } from "vue"; import { useI18n } from "vue-i18n"; import { useApolloClient, useSubscription } from "@vue/apollo-composable"; -import { Info, RefreshCw, UserMinus, UserPlus, Users } from "lucide-vue-next"; +import { Info, RefreshCw, UserMinus, Users } from "lucide-vue-next"; import { Button } from "~/components/ui/button"; import { Empty, @@ -11,18 +11,24 @@ import { } from "~/components/ui/empty"; import AnimatedFilters from "~/components/common/AnimatedFilters.vue"; import PlayerDisplay from "~/components/PlayerDisplay.vue"; +import PlayerElo from "~/components/PlayerElo.vue"; import TournamentChip from "~/components/tournament/TournamentChip.vue"; +import TournamentFreeAgentSignUp from "~/components/tournament/TournamentFreeAgentSignUp.vue"; import { toast } from "~/components/ui/toast"; import { $, e_tournament_status_enum } from "~/generated/zeus"; -import { generateMutation, generateSubscription } from "~/graphql/graphqlGen"; +import { generateSubscription } from "~/graphql/graphqlGen"; import { playerFields } from "~/graphql/playerFields"; import { useAuthStore } from "~/stores/AuthStore"; -import { useMatchmakingStore } from "~/stores/MatchmakingStore"; import { dateLocale } from "~/utilities/dateLocale"; import { tacticalSectionLabelClasses, tacticalSectionTickClasses, } from "~/utilities/tacticalClasses"; +import { runTournamentAction } from "~/utilities/tournamentActions"; +import { + tournamentEloLadder, + tournamentPlayerElo, +} from "~/utilities/tournamentElo"; const props = defineProps<{ tournament: Record; @@ -199,6 +205,14 @@ const teamSize = computed( 0, ); +// The ladder the DRAFT rates on, not the one the player looks best on: +// draft_tournament_free_agent_teams orders the pool by get_tournament_player_elo. +const eloLadder = computed(() => tournamentEloLadder(props.tournament)); + +function playerElo(agent: Agent) { + return tournamentPlayerElo(props.tournament, agent?.player); +} + // Only the first stage caps the field; later stages are fed by results. const stageMaxTeams = computed(() => { const max = props.tournament?.stages?.[0]?.max_teams; @@ -329,13 +343,11 @@ const draftedTeams = computed(() => { return [...groups.values()] .map((group) => { const elos = group.members - .map((member) => Number(member.player?.elo)) - .filter((elo) => Number.isFinite(elo)); + .map((member) => playerElo(member)) + .filter((elo): elo is number => elo !== null); const byElo = group.members .slice() - .sort( - (a, b) => Number(b.player?.elo ?? 0) - Number(a.player?.elo ?? 0), - ); + .sort((a, b) => (playerElo(b) ?? 0) - (playerElo(a) ?? 0)); return { ...group, // Grouped off an ELO-sorted list, so each unit keeps its members in @@ -374,7 +386,7 @@ const topRatedWaitlisted = computed(() => { return null; } const best = pool.value.reduce((top, row) => - Number(row.player?.elo ?? 0) > Number(top.player?.elo ?? 0) ? row : top, + (playerElo(row) ?? 0) > (playerElo(top) ?? 0) ? row : top, ); return best.status === "waitlisted" ? best : null; }); @@ -389,16 +401,6 @@ const myEntry = computed(() => { ); }); -// joinTournamentAsFreeAgent requires exactly RegistrationOpen, while the leave -// rule also allows Setup. Offering Sign up during Setup produces a button that -// can only ever fail, so the two windows are kept apart. -const canSignUp = computed( - () => - !!me.value && - props.tournament?.status === e_tournament_status_enum.RegistrationOpen && - !myEntry.value, -); - // The same window leaveTournamentAsFreeAgent enforces, drafted included: an // organizer can regenerate the teams while registration is still open, and // gating on that would strand everyone the draft touched with no way out of a @@ -413,56 +415,6 @@ const canLeave = computed( ].includes(props.tournament?.status), ); -// The party IS the matchmaking lobby — there is no separate invite to accept, -// because a captain who can already queue the whole lobby into a live match is -// making a strictly smaller commitment by entering it in a draft. Read straight -// off the store that owns lobby state; a second derivation of "who is in my -// lobby" is how the two answers drift apart. -const lobbyMembers = computed(() => { - const lobby = useMatchmakingStore().currentLobby as Agent | undefined; - // Only accepted members count — a pending invite is not consent, and the API - // sizes the party the same way. - return ((lobby?.players ?? []) as Agent[]).filter( - (member) => member.status === "Accepted", - ); -}); - -const lobbyCaptain = computed( - () => lobbyMembers.value.find((member) => member.captain) ?? null, -); - -const isLobbyCaptain = computed(() => { - const steamId = String(me.value?.steam_id ?? ""); - return ( - !!steamId && String(lobbyCaptain.value?.player?.steam_id ?? "") === steamId - ); -}); - -// A party of more than a full team can never be drafted, so the API refuses the -// signup outright. Signing up alone is the intended escape hatch. Mirrors -// tournament_free_agent_party_fits, which passes a format with no lineup size -// rather than measuring against a zero. -const lobbyFitsTeam = computed( - () => teamSize.value < 1 || lobbyMembers.value.length <= teamSize.value, -); - -const lobbyMembersAlreadyInPool = computed(() => { - const steamIds = new Set( - pool.value.map((row) => String(row.player?.steam_id)), - ); - return lobbyMembers.value.filter((member) => - steamIds.has(String(member.player?.steam_id)), - ).length; -}); - -const showLobbySignUp = computed( - () => canSignUp.value && lobbyMembers.value.length > 1, -); - -const canSignUpWithLobby = computed( - () => showLobbySignUp.value && isLobbyCaptain.value && lobbyFitsTeam.value, -); - function signUpTime(createdAt: string) { const date = new Date(createdAt); if (Number.isNaN(date.getTime())) { @@ -506,52 +458,9 @@ function statusLabel(status: string) { return t("tournament.free_agents.status_registered"); } -async function runAction( - mutation: Record, - failureTitle: string, -): Promise | null> { - try { - const { data } = await client.mutate({ - // Free-agent actions ship with the registration migration; zeus types - // for them only exist once `yarn codegen` has run against that schema. - mutation: generateMutation(mutation as any), - }); - return (data as Record) ?? null; - } catch (error: unknown) { - toast({ - title: failureTitle, - description: error instanceof Error ? error.message : String(error), - variant: "destructive", - }); - return null; - } -} - -async function joinPool(withParty = false) { - await runAction( - { - joinTournamentAsFreeAgent: [ - { - tournament_id: props.tournament.id, - // PRE-CODEGEN ESCAPE HATCH — `with_party` is a new action argument - // Zeus has not generated, so it rides the `as any` in runAction. - // Omitted entirely when false: that is byte-for-byte the old solo - // signup, which every unmigrated API still accepts. - ...(withParty ? { with_party: true } : {}), - }, - { - success: true, - }, - ], - }, - withParty - ? t("tournament.free_agents.join_with_lobby_failed") - : t("tournament.free_agents.join_failed"), - ); -} - async function leavePool() { - await runAction( + await runTournamentAction( + client, { leaveTournamentAsFreeAgent: [ { @@ -567,7 +476,8 @@ async function leavePool() { } async function draftTeams() { - const data = await runAction( + const data = await runTournamentAction( + client, { draftTournamentTeams: [ { @@ -628,16 +538,7 @@ async function draftTeams() { square /> - - - - + {{ $t("tournament.free_agents.empty_title") }} @@ -826,15 +634,18 @@ async function draftTeams() { :show-role="false" /> - - {{ row.agent.player?.elo ?? "—" }} + + + + — + - - {{ member.player?.elo ?? "—" }} + + + + — + diff --git a/components/tournament/TournamentJoinForm.vue b/components/tournament/TournamentJoinForm.vue index d085710c..57b7793c 100644 --- a/components/tournament/TournamentJoinForm.vue +++ b/components/tournament/TournamentJoinForm.vue @@ -9,8 +9,10 @@ import { FormSection, } from "~/components/ui/form"; import { Switch } from "~/components/ui/switch"; -import { MessageCircleWarning } from "lucide-vue-next"; +import { MessageCircleWarning, UserPlus, Users } from "lucide-vue-next"; +import AnimatedFilters from "~/components/common/AnimatedFilters.vue"; import PlayerSearch from "~/components/PlayerSearch.vue"; +import TournamentFreeAgentSignUp from "~/components/tournament/TournamentFreeAgentSignUp.vue"; import TeamSearch from "~/components/teams/TeamSearch.vue"; import { Card } from "~/components/ui/card"; import { Checkbox } from "~/components/ui/checkbox"; @@ -18,44 +20,31 @@ import PlayerDisplay from "~/components/PlayerDisplay.vue";