diff --git a/lang/dev/lobby.json b/lang/dev/lobby.json index 76b3095dc..9df141662 100644 --- a/lang/dev/lobby.json +++ b/lang/dev/lobby.json @@ -96,7 +96,9 @@ "declineInvite": "Denlice", "kickMember": "Kcik Meembr", "cancelInvite": "Ceancl Iivnte", - "noInvites": "No crerunt iiinnattvos." + "noInvites": "You crelnrtuy hvae no piendng iiinnattvos.", + "partyTooLarge": "Party Too Lgare", + "partyTooLargeTooltip": "Your party is too lgare to jion tihs qeuue." }, "watch": { "replays": { @@ -153,9 +155,11 @@ "searchGame": "Sacreh gmae", "joinRequested": "Jniniog qeuue", "searchingForOpponent": "Searnichg for oeonnppt", - "matchFound": "Mceathd - Ccilk to Raedy", - "accepted": "Acpteecd", - "cancel": "Ceancl" + "matchFound": "Ccilk to Raedy ({seconds})", + "accepted": "Acpteecd ({players} raedy)", + "cancel": "Ceancl", + "partyTooLarge": "Party Too Lgare", + "partyTooLargeTooltip": "Your party is too lgare to jion tihs qeuue." }, "modalTitle": "Macanihmtkg Quuee Daleits", "modal": { diff --git a/lang/en/lobby.json b/lang/en/lobby.json index 81c231c38..e84408a8c 100644 --- a/lang/en/lobby.json +++ b/lang/en/lobby.json @@ -96,7 +96,9 @@ "declineInvite": "Decline", "kickMember": "Kick Member", "cancelInvite": "Cancel Invite", - "noInvites": "No current invitations." + "noInvites": "You currently have no pending invitations.", + "partyTooLarge": "Party Too Large", + "partyTooLargeTooltip": "Your party is too large to join this queue." }, "watch": { "replays": { @@ -153,9 +155,11 @@ "searchGame": "Search game", "joinRequested": "Joining queue", "searchingForOpponent": "Searching for opponent", - "matchFound": "Matched - Click to Ready", - "accepted": "Accepted", - "cancel": "Cancel" + "matchFound": "Click to Ready ({seconds})", + "accepted": "Accepted ({players} ready)", + "cancel": "Cancel", + "partyTooLarge": "Party Too Large", + "partyTooLargeTooltip": "Your party is too large to join this queue." }, "modalTitle": "Matchmaking Queue Details", "modal": { diff --git a/src/renderer/assets/languages/cs.json b/src/renderer/assets/languages/cs.json index d847f8005..1ee4c453a 100644 --- a/src/renderer/assets/languages/cs.json +++ b/src/renderer/assets/languages/cs.json @@ -2079,7 +2079,9 @@ "declineInvite": null, "kickMember": null, "cancelInvite": null, - "noInvites": null + "noInvites": null, + "partyTooLarge": null, + "partyTooLargeTooltip": null }, "watch": { "replays": { @@ -2138,7 +2140,9 @@ "searchingForOpponent": null, "matchFound": null, "accepted": null, - "cancel": null + "cancel": null, + "partyTooLarge": null, + "partyTooLargeTooltip": null }, "modalTitle": null, "modal": { diff --git a/src/renderer/assets/languages/de.json b/src/renderer/assets/languages/de.json index ef377fd8f..e2019f3ae 100644 --- a/src/renderer/assets/languages/de.json +++ b/src/renderer/assets/languages/de.json @@ -1950,7 +1950,9 @@ "declineInvite": null, "kickMember": null, "cancelInvite": null, - "noInvites": null + "noInvites": null, + "partyTooLarge": null, + "partyTooLargeTooltip": null }, "watch": { "replays": { @@ -2009,7 +2011,9 @@ "searchingForOpponent": null, "matchFound": null, "accepted": null, - "cancel": null + "cancel": null, + "partyTooLarge": null, + "partyTooLargeTooltip": null }, "modalTitle": null, "modal": { diff --git a/src/renderer/assets/languages/dev.json b/src/renderer/assets/languages/dev.json index ac270d559..55ea3a297 100644 --- a/src/renderer/assets/languages/dev.json +++ b/src/renderer/assets/languages/dev.json @@ -96,7 +96,9 @@ "declineInvite": "Denlice", "kickMember": "Kcik Meembr", "cancelInvite": "Ceancl Iivnte", - "noInvites": "No crerunt iiinnattvos." + "noInvites": "You crelnrtuy hvae no piendng iiinnattvos.", + "partyTooLarge": "Party Too Lgare", + "partyTooLargeTooltip": "Your party is too lgare to jion tihs qeuue." }, "watch": { "replays": { @@ -153,9 +155,11 @@ "searchGame": "Sacreh gmae", "joinRequested": "Jniniog qeuue", "searchingForOpponent": "Searnichg for oeonnppt", - "matchFound": "Mceathd - Ccilk to Raedy", - "accepted": "Acpteecd", - "cancel": "Ceancl" + "matchFound": "Ccilk to Raedy ({seconds})", + "accepted": "Acpteecd ({players} raedy)", + "cancel": "Ceancl", + "partyTooLarge": "Party Too Lgare", + "partyTooLargeTooltip": "Your party is too lgare to jion tihs qeuue." }, "modalTitle": "Macanihmtkg Quuee Daleits", "modal": { diff --git a/src/renderer/assets/languages/en.json b/src/renderer/assets/languages/en.json index f8ffd8734..2d7298e97 100644 --- a/src/renderer/assets/languages/en.json +++ b/src/renderer/assets/languages/en.json @@ -1927,7 +1927,9 @@ "declineInvite": "Decline", "kickMember": "Kick Member", "cancelInvite": "Cancel Invite", - "noInvites": "No current invitations." + "noInvites": "You currently have no pending invitations.", + "partyTooLarge": "Party Too Large", + "partyTooLargeTooltip": "Your party is too large to join this queue." }, "watch": { "replays": { @@ -1984,9 +1986,11 @@ "searchGame": "Search game", "joinRequested": "Joining queue", "searchingForOpponent": "Searching for opponent", - "matchFound": "Matched - Click to Ready", - "accepted": "Accepted", - "cancel": "Cancel" + "matchFound": "Click to Ready ({seconds})", + "accepted": "Accepted ({players} ready)", + "cancel": "Cancel", + "partyTooLarge": "Party Too Large", + "partyTooLargeTooltip": "Your party is too large to join this queue." }, "modalTitle": "Matchmaking Queue Details", "modal": { diff --git a/src/renderer/assets/languages/fr.json b/src/renderer/assets/languages/fr.json index f5b150161..f448c0969 100644 --- a/src/renderer/assets/languages/fr.json +++ b/src/renderer/assets/languages/fr.json @@ -3932,7 +3932,9 @@ "declineInvite": null, "kickMember": null, "cancelInvite": null, - "noInvites": null + "noInvites": null, + "partyTooLarge": null, + "partyTooLargeTooltip": null }, "watch": { "replays": { @@ -3991,7 +3993,9 @@ "searchingForOpponent": null, "matchFound": null, "accepted": null, - "cancel": null + "cancel": null, + "partyTooLarge": null, + "partyTooLargeTooltip": null }, "modalTitle": null, "modal": { diff --git a/src/renderer/assets/languages/ru.json b/src/renderer/assets/languages/ru.json index cfb57d879..50a567c18 100644 --- a/src/renderer/assets/languages/ru.json +++ b/src/renderer/assets/languages/ru.json @@ -3901,7 +3901,9 @@ "declineInvite": null, "kickMember": null, "cancelInvite": null, - "noInvites": null + "noInvites": null, + "partyTooLarge": null, + "partyTooLargeTooltip": null }, "watch": { "replays": { @@ -3960,7 +3962,9 @@ "searchingForOpponent": null, "matchFound": null, "accepted": null, - "cancel": null + "cancel": null, + "partyTooLarge": null, + "partyTooLargeTooltip": null }, "modalTitle": null, "modal": { diff --git a/src/renderer/assets/languages/zh.json b/src/renderer/assets/languages/zh.json index 2210de24a..1178305bb 100644 --- a/src/renderer/assets/languages/zh.json +++ b/src/renderer/assets/languages/zh.json @@ -4043,7 +4043,9 @@ "declineInvite": null, "kickMember": null, "cancelInvite": null, - "noInvites": null + "noInvites": null, + "partyTooLarge": null, + "partyTooLargeTooltip": null }, "watch": { "replays": { @@ -4102,7 +4104,9 @@ "searchingForOpponent": null, "matchFound": null, "accepted": null, - "cancel": null + "cancel": null, + "partyTooLarge": null, + "partyTooLargeTooltip": null }, "modalTitle": null, "modal": { diff --git a/src/renderer/composables/usePartySizeMatchmaking.ts b/src/renderer/composables/usePartySizeMatchmaking.ts new file mode 100644 index 000000000..7ab43ebea --- /dev/null +++ b/src/renderer/composables/usePartySizeMatchmaking.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2025 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { matchmakingStore } from "@renderer/store/matchmaking.store"; +import { partyStore } from "@renderer/store/party.store"; +import { ref, watch } from "vue"; + +export function usePartySizeMatchmaking() { + const partyTooLarge = ref(false); + + watch( + () => ({ + activeParty: partyStore.activeParty, + queueId: matchmakingStore.selectedQueue, + playlists: [...matchmakingStore.playlists], + // Spreading map entries forces Vue to evaluate the Map's contents + members: [...(partyStore.parties.get(partyStore.activeParty ?? "")?.members ?? [])], + invited: [...(partyStore.parties.get(partyStore.activeParty ?? "")?.invited ?? [])], + }), + (newData) => { + const { activeParty, queueId, playlists } = newData; + + if (!activeParty || !queueId || !playlists.length) { + partyTooLarge.value = false; + return; + } + + const party = partyStore.parties.get(activeParty); + const playlist = playlists.find((p) => p.id === queueId); + + if (!party || !playlist) { + partyTooLarge.value = false; + return; + } + + partyTooLarge.value = party.members.length > playlist.teamSize; + }, + { immediate: true } + ); + + return { + partyTooLarge, + }; +} + +export function getPartySize() { + const activeParty = partyStore.activeParty; + if (!activeParty) { + return 0; + } + + const party = partyStore.parties.get(activeParty); + if (!party) { + return 0; + } + + return party.members.length; +} diff --git a/src/renderer/store/matchmaking.store.ts b/src/renderer/store/matchmaking.store.ts index e7b8cd523..9f1cef490 100644 --- a/src/renderer/store/matchmaking.store.ts +++ b/src/renderer/store/matchmaking.store.ts @@ -16,6 +16,11 @@ import { notificationsApi } from "@renderer/api/notifications"; import { isTachyonErrorForCommand, tachyonRequest } from "@renderer/api/tachyon"; import { onWentOffline } from "@renderer/utils/offline-signal"; +// The server is the authority on the real ready-up deadline and will send its own +// matchmaking/cancelled event if we miss it; this margin just narrows the window +// where our local countdown and the server's deadline can disagree. +const READY_TIMEOUT_SAFETY_MARGIN_MS = 100; + export enum MatchmakingStatus { Idle = "Idle", JoinRequested = "JoinRequested", @@ -43,6 +48,9 @@ export const matchmakingStore: { maps: string[]; }; }; + queueTimeout?: number; + readyCountdownInterval?: number; + readySecondsRemaining?: number; } = reactive({ isInitialized: false, isDrawerOpen: false, @@ -55,8 +63,23 @@ export const matchmakingStore: { playersReady: 0, playersQueued: 0, downloadsRequired: {}, + queueTimeout: undefined, + readyCountdownInterval: undefined, + readySecondsRemaining: undefined, }); +function clearReadyTimers() { + if (matchmakingStore.queueTimeout !== undefined) { + window.clearTimeout(matchmakingStore.queueTimeout); + matchmakingStore.queueTimeout = undefined; + } + if (matchmakingStore.readyCountdownInterval !== undefined) { + window.clearInterval(matchmakingStore.readyCountdownInterval); + matchmakingStore.readyCountdownInterval = undefined; + } + matchmakingStore.readySecondsRemaining = undefined; +} + function onQueueUpdateEvent(data: MatchmakingQueueUpdateEventData) { console.log("Tachyon event: matchmaking/queueUpdate:", data); matchmakingStore.playersQueued = data.playersQueued; @@ -64,6 +87,7 @@ function onQueueUpdateEvent(data: MatchmakingQueueUpdateEventData) { function onLostEvent() { console.log("Tachyon event: matchmaking/lost: no data"); + clearReadyTimers(); matchmakingStore.status = MatchmakingStatus.Searching; } @@ -74,6 +98,7 @@ function onFoundUpdateEvent(data: MatchmakingFoundUpdateEventData) { function onCancelledEvent(data: MatchmakingCancelledEventData) { console.log("Tachyon event: matchmaking/cancelled:", data); + clearReadyTimers(); matchmakingStore.status = MatchmakingStatus.Idle; if (data.reason === "version_changed") { sendListRequest(); @@ -82,16 +107,24 @@ function onCancelledEvent(data: MatchmakingCancelledEventData) { function onFoundEvent(data: MatchmakingFoundEventData) { console.log("Tachyon event: matchmaking/found:", data); + clearReadyTimers(); matchmakingStore.status = MatchmakingStatus.MatchFound; - // Per spec, we have 10 seconds to send the ``matchmaking/ready`` request or we get cancelled from queue. - // Probably better to track this timer on the UI side because the user will either need to 'ready' or 'cancel' - // and they need to know this. Plus the UI has to "pop up" because they need to respond to it. - // But we don't want to be "triggering" the UI from the store. Instead, we should add a watcher, - // and when this value updates to MatchFound we can start our timer. Probably want a progress bar "counting down" too. + // Deadline is our local prediction of the server's timeout, not the source of truth for it. + const deadline = Date.now() + data.timeoutMs - READY_TIMEOUT_SAFETY_MARGIN_MS; + matchmakingStore.readySecondsRemaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000)); + matchmakingStore.readyCountdownInterval = window.setInterval(() => { + matchmakingStore.readySecondsRemaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000)); + }, 1000); + matchmakingStore.queueTimeout = window.setTimeout(() => { + clearReadyTimers(); + matchmakingStore.status = MatchmakingStatus.Idle; + }, deadline - Date.now()); } function onQueuesJoinedEvent(data: MatchmakingQueuesJoinedEventData) { console.log("Tachyon event: matchmaking/queuesJoined:", data); + // Current design is a single queue joined at a time. If the user has a *different* queue selected when their party joins, we switch so they match their party's selection. + matchmakingStore.selectedQueue = data.queues[0]; notificationsApi.alert({ text: "Successfully joined matchmaking queue(s)", severity: "info" }); matchmakingStore.status = MatchmakingStatus.Searching; } @@ -211,6 +244,7 @@ async function sendQueueRequest() { * Sends a Tachyon 'matchmaking/cancel' request. */ async function sendCancelRequest() { + clearReadyTimers(); matchmakingStore.status = MatchmakingStatus.Idle; try { const response = await window.tachyon.request("matchmaking/cancel"); @@ -226,6 +260,7 @@ async function sendCancelRequest() { * Sends a Tachyon 'matchmaking/ready' request. */ async function sendReadyRequest() { + clearReadyTimers(); matchmakingStore.status = MatchmakingStatus.MatchAccepted; try { const response = await window.tachyon.request("matchmaking/ready"); @@ -264,6 +299,7 @@ export async function initializeMatchmakingStore() { // selectedQueue is the user's pick, not server state, and downloadsRequired is derived from // installed content rather than the session - the next list response recomputes it. export function clearOnlineState() { + clearReadyTimers(); matchmakingStore.status = MatchmakingStatus.Idle; matchmakingStore.playlists = []; matchmakingStore.isLoadingQueues = false; diff --git a/src/renderer/views/play/matchmaking.vue b/src/renderer/views/play/matchmaking.vue index a5480c78e..138ea2f33 100644 --- a/src/renderer/views/play/matchmaking.vue +++ b/src/renderer/views/play/matchmaking.vue @@ -39,6 +39,13 @@ SPDX-License-Identifier: MIT
+
+ +
@@ -53,6 +60,9 @@ SPDX-License-Identifier: MIT >This string should never be visible.
+