Skip to content
Merged
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 components/matchmaking/Matchmaking.vue
Original file line number Diff line number Diff line change
Expand Up @@ -302,15 +302,15 @@ function releaseSwapHeight(el: Element): void {
</span>
<span
v-if="
distinctInQueue(
playersInQueue(
matchMakingQueueDetails.type,
matchMakingQueueDetails.regions,
) > 0
"
class="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/30 px-2.5 py-0.5 font-mono text-[0.65rem] uppercase tracking-[0.18em] text-muted-foreground"
>
{{
distinctInQueue(
playersInQueue(
matchMakingQueueDetails.type,
matchMakingQueueDetails.regions,
)
Expand Down Expand Up @@ -378,14 +378,14 @@ function releaseSwapHeight(el: Element): void {
variant="secondary"
class="absolute top-2 right-2 px-2 py-0.5 text-[0.65rem] tracking-[0.12em] uppercase transition-opacity duration-200"
v-if="
distinctInQueue(
playersInQueue(
type.value,
preferredRegions.map((region) => region.value),
) > 0
"
>
{{
distinctInQueue(
playersInQueue(
type.value,
preferredRegions.map((region) => region.value),
)
Expand Down Expand Up @@ -450,6 +450,7 @@ import { e_match_types_enum, e_match_status_enum } from "~/generated/zeus";
import { toast } from "@/components/ui/toast";
import {
EXPECTED_PLAYERS,
playersInQueue as countPlayersInQueue,
canPartyQueue,
} from "~/utilities/matchmakingPartySize";

Expand Down Expand Up @@ -572,18 +573,8 @@ export default {
canQueueType(type: e_match_types_enum): boolean {
return canPartyQueue(type, this.partySize);
},
distinctInQueue(type: e_match_types_enum, regionValues: string[]): number {
const lobbyIndexes = new Set<number>();
for (const regionValue of regionValues) {
const indexes = this.regionStats[regionValue]?.[type];
if (!indexes) {
continue;
}
for (const index of indexes) {
lobbyIndexes.add(index);
}
}
return lobbyIndexes.size;
playersInQueue(type: e_match_types_enum, regionValues: string[]): number {
return countPlayersInQueue(this.regionStats, type, regionValues);
},
getRegionlatencyResult(region: string):
| {
Expand Down
4 changes: 3 additions & 1 deletion components/notification/NotificationContext.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import { typedGql } from "~/generated/zeus/typedDocumentNode";
const MATCH_TYPES = ["MatchStatusChange", "MatchSupport", "MatchAbandoned"];
const SERVER_TYPES = ["DedicatedServerStatus", "DedicatedServerRconStatus"];
const NODE_TYPES = ["GameNodeStatus"];
const PLAYER_TYPES = ["PlayerSanctioned"];
// NameChangeRequest carries the requesting player's steam id, so an admin can
// see who is asking without leaving the bell.
const PLAYER_TYPES = ["PlayerSanctioned", "NameChangeRequest"];
const SCRIM_TYPES = [
"ScrimRequestReceived",
"ScrimRequestCountered",
Expand Down
37 changes: 11 additions & 26 deletions components/notification/NotificationItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -132,32 +132,17 @@ onBeforeUnmount(() => {
{{ notification.title }}
</h3>

<template v-if="notification.type !== 'NameChangeRequest'">
<p
class="[&_a]:text-[hsl(var(--tac-amber))] [&_a]:underline [&_a:hover]:text-[hsl(var(--tac-amber)/0.8)]"
:class="[
'text-sm mb-2',
notification.is_read
? 'text-muted-foreground/70'
: 'text-muted-foreground',
]"
>
<NotificationMessage :html="notification.message" />
</p>
</template>
<template v-else>
<p
class="[&_a]:text-[hsl(var(--tac-amber))] [&_a]:underline [&_a:hover]:text-[hsl(var(--tac-amber)/0.8)]"
:class="[
'text-sm mb-2',
notification.is_read
? 'text-muted-foreground/70'
: 'text-muted-foreground',
]"
>
{{ notification.message }}
</p>
</template>
<p
class="[&_a]:text-[hsl(var(--tac-amber))] [&_a]:underline [&_a:hover]:text-[hsl(var(--tac-amber)/0.8)]"
:class="[
'text-sm mb-2',
notification.is_read
? 'text-muted-foreground/70'
: 'text-muted-foreground',
]"
>
<NotificationMessage :html="notification.message" />
</p>

<NotificationContext
v-if="
Expand Down
10 changes: 0 additions & 10 deletions components/notification/NotificationStack.vue
Original file line number Diff line number Diff line change
Expand Up @@ -190,23 +190,13 @@ function handleTopClick(event: MouseEvent) {
</h3>

<p
v-if="top.type !== 'NameChangeRequest'"
class="[&_a]:text-[hsl(var(--tac-amber))] [&_a]:underline [&_a:hover]:text-[hsl(var(--tac-amber)/0.8)] text-sm mt-1 line-clamp-2"
:class="
top.is_read ? 'text-muted-foreground/70' : 'text-muted-foreground'
"
>
<NotificationMessage :html="top.message" />
</p>
<p
v-else
class="text-sm mt-1 line-clamp-2"
:class="
top.is_read ? 'text-muted-foreground/70' : 'text-muted-foreground'
"
>
{{ top.message }}
</p>

<NotificationContext
v-if="top.entity_id && top.steam_id !== top.entity_id"
Expand Down
1 change: 1 addition & 0 deletions pages/highlights/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,7 @@ const viewModeOptions = computed<
<PlayerSearch
v-else
:label="$t('pages.highlights.filter_by_player')"
:self="true"
@selected="selectPlayer"
>
<button
Expand Down
70 changes: 42 additions & 28 deletions pages/players/[id].vue
Original file line number Diff line number Diff line change
Expand Up @@ -1956,14 +1956,31 @@ const playerHeroTeamChipDotClasses =
v-if="canAddFriend"
type="button"
:class="[playerHeroAddFriendClasses, 'flex-1']"
:disabled="addFriendPending"
:disabled="friendActionPending"
@click="addAsFriend"
>
<UserPlus class="h-4 w-4" />
<span>{{ $t("player.status.add_friend") }}</span>
</button>
<button
v-else-if="friendRelationship === 'incoming'"
type="button"
:class="[playerHeroAddFriendClasses, 'flex-1']"
:disabled="friendActionPending"
@click="acceptFriendRequest"
>
<UserCheck class="h-4 w-4" />
<span>{{ $t("matchmaking.friends.accept") }}</span>
</button>
<span
v-else-if="friendRelationship === 'outgoing'"
:class="[playerHeroFriendBadgeClasses, 'flex-1']"
>
<UserPlus class="h-3.5 w-3.5" />
<span>{{ $t("matchmaking.friends.requested") }}</span>
</span>
<span
v-else-if="isFriend"
v-else-if="friendRelationship === 'friend'"
:class="[playerHeroFriendBadgeClasses, 'flex-1']"
>
<UserCheck class="h-3.5 w-3.5" />
Expand Down Expand Up @@ -3257,7 +3274,6 @@ export default {
};
}>,
editPlayerSheet: false,
addFriendPending: false,
};
},
computed: {
Expand Down Expand Up @@ -3365,33 +3381,39 @@ export default {
this.player.steam_id === this.me.steam_id
);
},
isFriend() {
if (!this.player) {
return false;
// none / outgoing / incoming / friend. A my_friends row on its own only
// means somebody asked, which is why this is not a boolean.
friendRelationship() {
if (!this.player?.steam_id) {
return "none";
}
return !!useMatchmakingStore().friends.find((friend: any) => {
return friend.steam_id == this.player.steam_id;
});
return useFriendActions().relationship(this.player.steam_id);
},
friendActionPending() {
return (
!!this.player?.steam_id &&
useFriendActions().isBusy(this.player.steam_id)
);
},
canAddFriend() {
return !!(
this.me &&
this.player?.steam_id &&
!this.isSelfProfile &&
!this.isFriend
this.friendRelationship === "none"
);
},
hasRightColumn() {
return (
this.isSelfProfile ||
this.canAddFriend ||
this.isFriend ||
this.friendRelationship !== "none" ||
this.canMessage
);
},
// Deliberately not `isFriend`, which matches any my_friends row including a
// still-pending request. The server only opens a conversation between
// accepted friends, so anything looser renders a button that fails.
// Deliberately not a plain my_friends lookup, which matches a still-pending
// request too. The server only opens a conversation between accepted
// friends, so anything looser renders a button that fails.
canMessage() {
return (
!!this.player && useDirectMessages().canMessage(this.player.steam_id)
Expand Down Expand Up @@ -3503,20 +3525,12 @@ export default {
});
},
async addAsFriend() {
if (!this.player?.steam_id || this.addFriendPending) return;
this.addFriendPending = true;
try {
await this.$apollo.mutate({
mutation: typedGql("mutation")({
insert_my_friends_one: [
{ object: { steam_id: this.player.steam_id } },
{ steam_id: true },
],
}),
});
} finally {
this.addFriendPending = false;
}
if (!this.player?.steam_id || this.friendActionPending) return;
await useFriendActions().addFriend(this.player.steam_id);
},
async acceptFriendRequest() {
if (!this.player?.steam_id || this.friendActionPending) return;
await useFriendActions().acceptFriend(this.player.steam_id);
},
handleImageError(event) {
const img = event.target;
Expand Down
11 changes: 10 additions & 1 deletion pages/teams/[id].vue
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ const teamHeroActionsClasses =
{{ $t("common.actions.delete") }}
</DropdownMenuItem>
</template>
<template v-if="isOnTeam">
<template v-if="isOnTeam && !isTeamOwner">
<DropdownMenuItem
class="text-destructive focus:text-destructive"
@click="leaveTeamAlertDialog = true"
Expand Down Expand Up @@ -698,6 +698,15 @@ export default {
return player.steam_id === this.me?.steam_id;
});
},
// The owner has to hand the team over before they can walk away from it,
// otherwise nobody left on the roster can manage it. The database enforces
// the same rule.
isTeamOwner() {
return (
!!this.me?.steam_id &&
String(this.team?.owner_steam_id) === String(this.me.steam_id)
);
},
isAdmin() {
return useAuthStore().isAdmin;
},
Expand Down
5 changes: 2 additions & 3 deletions stores/MatchmakingStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import getGraphqlClient from "~/graphql/getGraphqlClient";
import { generateQuery, generateSubscription } from "~/graphql/graphqlGen";
import { playerFields } from "~/graphql/playerFields";
import { isInCs2 } from "~/utilities/cs2Presence";
import type { RegionStats } from "~/utilities/matchmakingPartySize";
import { typedGql } from "~/generated/zeus/typedDocumentNode";
import { setActiveHub } from "~/composables/useHubState";

Expand Down Expand Up @@ -63,9 +64,7 @@ export const useMatchmakingStore = defineStore("matchmaking", () => {
confirmation: undefined,
});

const regionStats = ref<
Partial<Record<string, Partial<Record<e_match_types_enum, number[]>>>>
>({});
const regionStats = ref<RegionStats>({});

const queryPlayers = async () => {
const steamIds = onlinePlayerSteamIds.value;
Expand Down
43 changes: 43 additions & 0 deletions tests/utilities/matchmakingQueueCounts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { playersInQueue } from "~/utilities/matchmakingPartySize";
import { e_match_types_enum } from "~/generated/zeus";

const COMPETITIVE = e_match_types_enum.Competitive;

describe("playersInQueue", () => {
it("counts players rather than lobbies", () => {
const stats = {
"us-east": {
[COMPETITIVE]: [
{ lobby: 0, players: 3 },
{ lobby: 1, players: 1 },
],
},
};

expect(playersInQueue(stats, COMPETITIVE, ["us-east"])).toBe(4);
});

it("counts a lobby queued in several regions once", () => {
const stats = {
"us-east": { [COMPETITIVE]: [{ lobby: 0, players: 2 }] },
"eu-west": { [COMPETITIVE]: [{ lobby: 0, players: 2 }] },
};

expect(playersInQueue(stats, COMPETITIVE, ["us-east", "eu-west"])).toBe(2);
});

it("adds up the regions the player is actually queued for", () => {
const stats = {
"us-east": { [COMPETITIVE]: [{ lobby: 0, players: 2 }] },
"eu-west": { [COMPETITIVE]: [{ lobby: 1, players: 5 }] },
"ap-south": { [COMPETITIVE]: [{ lobby: 2, players: 4 }] },
};

expect(playersInQueue(stats, COMPETITIVE, ["us-east", "eu-west"])).toBe(7);
});

it("is zero when nothing is queued", () => {
expect(playersInQueue({}, COMPETITIVE, ["us-east"])).toBe(0);
});
});
36 changes: 36 additions & 0 deletions utilities/matchmakingPartySize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,39 @@ export function canPartyQueue(

return partySize <= expected / 2 || partySize === expected;
}

/**
* One lobby waiting in a region's queue, as the api broadcasts it. A lobby that
* queued for several regions carries the same `lobby` index in each of them.
*/
export interface QueuedLobbyStat {
lobby: number;
players: number;
}

export type RegionStats = Partial<
Record<string, Partial<Record<e_match_types_enum, QueuedLobbyStat[]>>>
>;

/**
* How many players are waiting for a match type across the given regions.
*
* Counts people, not parties: a queued trio is three players waiting. Lobbies
* are deduplicated by index, so a lobby queued in three regions is counted
* once rather than three times.
*/
export function playersInQueue(
regionStats: RegionStats,
type: e_match_types_enum,
regionValues: string[],
): number {
const counted = new Map<number, number>();

for (const regionValue of regionValues) {
for (const queued of regionStats[regionValue]?.[type] ?? []) {
counted.set(queued.lobby, queued.players);
}
}

return [...counted.values()].reduce((total, players) => total + players, 0);
}
Loading