diff --git a/locales/en.json b/locales/en.json index d435cee74..cbd54dbf1 100644 --- a/locales/en.json +++ b/locales/en.json @@ -451,7 +451,9 @@ "autoBest": "Auto (Best)", "searchPlaceholder": "Search regions...", "refreshPing": "Refresh ping", - "noRegionsMatch": "No regions match \"{{query}}\"" + "noRegionsMatch": "No regions match \"{{query}}\"", + "autoRejoin": "Auto Rejoin", + "autoRejoinHint": "Automatically picks the best server using your ping and queue, and automatically rejoins the game when your session ends." }, "persistentStorage": { "title": "Persistent Storage", diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index cf762111c..2f7d02ab4 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -1164,6 +1164,8 @@ function registerIpcHandlers(): void { }, ); + + // Logs export IPC handler ipcMain.handle( IPC_CHANNELS.LOGS_EXPORT, diff --git a/opennow-stable/src/main/services/printedWaste.ts b/opennow-stable/src/main/services/printedWaste.ts index dfc5f0768..664e4cbcb 100644 --- a/opennow-stable/src/main/services/printedWaste.ts +++ b/opennow-stable/src/main/services/printedWaste.ts @@ -3,6 +3,7 @@ import type { PrintedWasteServerMapping, } from "@shared/gfn"; import { fetchWithTimeout, withTimeout } from "./requestTimeout"; +import { fetchWithOptionalProxy } from "../gfn/proxyFetch"; const PRINTEDWASTE_TIMEOUT_MS = 7000; const PRINTEDWASTE_QUEUE_URL = "https://api.printedwaste.com/gfn/queue/"; @@ -11,18 +12,29 @@ const PRINTEDWASTE_SERVER_MAPPING_URL = export async function fetchPrintedWasteQueue( appVersion: string, + proxyUrl?: string, ): Promise { - const response = await fetchWithTimeout( - PRINTEDWASTE_QUEUE_URL, - { - headers: { - "User-Agent": `opennow/${appVersion}`, - Accept: "application/json", - }, - }, + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(new Error("PrintedWaste queue request timed out after 7000ms")), PRINTEDWASTE_TIMEOUT_MS, - "PrintedWaste queue request", ); + let response: Response; + try { + response = await fetchWithOptionalProxy( + PRINTEDWASTE_QUEUE_URL, + { + headers: { + "User-Agent": `opennow/${appVersion}`, + Accept: "application/json", + }, + signal: controller.signal, + }, + proxyUrl, + ); + } finally { + clearTimeout(timeoutId); + } if (!response.ok) { throw new Error(`PrintedWaste API returned HTTP ${response.status}`); } diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index 9cc4b8537..1c875ffdb 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -153,6 +153,8 @@ export interface Settings { autoCheckForUpdates: boolean; /** When true, pressing Escape will exit fullscreen; when false Escape is sent to the game while pointer-locked */ allowEscapeToExitFullscreen?: boolean; + /** Automatically select a server and rejoin after a free-tier session ends. */ + enableFastQueueJoin: boolean; /** Last version for which the release highlights modal was acknowledged (empty = never) */ lastSeenReleaseHighlightsVersion: string; /** Client-side GPU post-processing shaders applied to the stream (web client mode) */ @@ -259,6 +261,7 @@ const DEFAULT_SETTINGS: Settings = { discordRichPresence: false, autoCheckForUpdates: true, allowEscapeToExitFullscreen: false, + enableFastQueueJoin: false, lastSeenReleaseHighlightsVersion: "", videoShader: { ...DEFAULT_VIDEO_SHADER_SETTINGS }, }; diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 77a78403b..63c627e30 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -74,7 +74,7 @@ import { sortLibraryGames, } from "./lib/gameCatalog"; import { chooseAccountLinked, getEpicOwnershipLaunchError, resolveInstallToPlayStorageRegionUrl } from "./lib/launchOwnership"; -import { hasAnyEligiblePrintedWasteZone, isAllianceStreamingBaseUrl } from "./lib/printedWaste"; +import { hasAnyEligiblePrintedWasteZone, isAllianceStreamingBaseUrl, pickBestPrintedWasteZone, constructPrintedWasteZoneUrl, isStandardPrintedWasteZone, isPrintedWasteZoneFresh } from "./lib/printedWaste"; import { mergePolledSessionState, normalizeMembershipTier, @@ -548,6 +548,7 @@ export function App(): JSX.Element { autoCheckForUpdates: true, lastSeenReleaseHighlightsVersion: "", videoShader: { ...DEFAULT_VIDEO_SHADER_SETTINGS }, + enableFastQueueJoin: false, }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [releaseHighlightsPayload, setReleaseHighlightsPayload] = useState(null); @@ -637,6 +638,13 @@ export function App(): JSX.Element { streamingGameRef.current = streamingGame; }, [streamingGame]); + const handlePlayGameRef = useRef<((game: GameInfo, options?: { bypassGuards?: boolean; streamingBaseUrl?: string; variantId?: string }) => Promise) | null>(null); + const prePingSmartUrlRef = useRef(null); + const prePingInFlightRef = useRef(false); + const prePingPromiseRef = useRef | null>(null); + const consecutiveAutoRejoinAttemptsRef = useRef(0); + const autoRejoinSessionIdRef = useRef(null); + const resetStatsOverlayToPreference = useCallback((): void => { setShowStatsOverlay(settings.showStatsOnLaunch); }, [settings.showStatsOnLaunch]); @@ -773,6 +781,9 @@ export function App(): JSX.Element { window.clearTimeout(stableRecoveryResetTimerRef.current); stableRecoveryResetTimerRef.current = null; } + prePingSmartUrlRef.current = null; + prePingInFlightRef.current = false; + prePingPromiseRef.current = null; if (remoteIceGraceTimerRef.current !== null) { window.clearTimeout(remoteIceGraceTimerRef.current); remoteIceGraceTimerRef.current = null; @@ -1673,6 +1684,81 @@ export function App(): JSX.Element { previousFreeTierRemainingSecondsRef.current = freeTierSessionRemainingSeconds; }, [freeTierSessionRemainingSeconds]); + // Auto-Rejoin: pre-fetch best server URL using the same weighted algorithm as QueueServerSelectModal + // (75% ping weight + 25% queue weight). Runs when ~15s remain in the session. + useEffect(() => { + if (!settings.enableFastQueueJoin) return; + const activeProvider = authSession?.provider ?? selectedProvider; + const isNvidiaAccount = isNvidiaProvider(activeProvider); + const isAllianceServer = isAllianceStreamingBaseUrl(effectiveStreamingBaseUrl); + if (!isNvidiaAccount || isAllianceServer) return; + + if ( + sessionTimeRemainingSeconds === null || + sessionTimeRemainingSeconds > 15 || + prePingSmartUrlRef.current !== null || + prePingInFlightRef.current + ) return; + + const targetSessionId = sessionRef.current?.sessionId; + prePingInFlightRef.current = true; + console.log("[AutoRejoin] Fetching queue + pinging servers at", sessionTimeRemainingSeconds, "s remaining"); + + const prefetchPromise = (async (): Promise => { + try { + const [queueData, serverMapping] = await Promise.all([ + window.openNow.fetchPrintedWasteQueue(), + window.openNow.fetchPrintedWasteServerMapping().catch(() => null), + ]); + + const nukedIds = new Set(); + if (serverMapping) { + for (const [zoneId, meta] of Object.entries(serverMapping)) { + if (meta.nuked) nukedIds.add(zoneId); + } + } + + const candidates = Object.entries(queueData) + .filter(([zoneId, zone]) => isStandardPrintedWasteZone(zoneId) && isPrintedWasteZoneFresh(zone) && !nukedIds.has(zoneId)) + .map(([zoneId]) => ({ + zoneId, + routingUrl: constructPrintedWasteZoneUrl(zoneId), + })); + + if (candidates.length === 0) { + console.warn("[AutoRejoin] No valid candidate zones found"); + return null; + } + + const regionsToTest = candidates.map((c) => ({ name: c.zoneId, url: c.routingUrl })); + const pingResults = await window.openNow.pingRegions(regionsToTest); + const pingMap = new Map(pingResults.map((r) => [r.url, r.pingMs])); + + const best = pickBestPrintedWasteZone(queueData, serverMapping, pingMap); + if (!best) { + console.warn("[AutoRejoin] No best server found by the picker algorithm"); + return null; + } + + if (!prePingInFlightRef.current || (targetSessionId && sessionRef.current?.sessionId !== targetSessionId)) { + console.log("[AutoRejoin] Pre-fetch completed after session reset or end — ignoring stale result"); + return null; + } + + prePingSmartUrlRef.current = best.routingUrl; + console.log(`[AutoRejoin] Best server: ${best.zoneId} (ping: ${best.pingMs}ms, queue: ${best.queuePosition})`); + return best.routingUrl; + } catch (err) { + console.error("[AutoRejoin] Pre-fetch failed:", err); + return null; + } finally { + prePingInFlightRef.current = false; + } + })(); + + prePingPromiseRef.current = prefetchPromise; + }, [authSession, effectiveStreamingBaseUrl, selectedProvider, sessionTimeRemainingSeconds, settings.enableFastQueueJoin]); + useEffect(() => { if (!localSessionTimerWarning) return; @@ -2956,6 +3042,118 @@ export function App(): JSX.Element { resolveSubscriptionInfoForLaunch, ]); + const startAutoRejoin = useCallback((reason: string, testGameOverride?: GameInfo | null): boolean => { + const game = testGameOverride !== undefined ? testGameOverride : streamingGameRef.current; + const playGame = handlePlayGameRef.current; + const cachedUrl = prePingSmartUrlRef.current; + + console.log("[AutoRejoin] *** startAutoRejoin called ***", { + reason, + enabled: settings.enableFastQueueJoin, + cachedUrl, + prefetchInFlight: prePingInFlightRef.current, + game: game?.title ?? null, + hasLaunchHandler: Boolean(playGame), + consecutiveAttempts: consecutiveAutoRejoinAttemptsRef.current, + }); + + if (!settings.enableFastQueueJoin) { + console.log("[AutoRejoin] Skipping: enableFastQueueJoin is OFF"); + return false; + } + + const currentSessionId = sessionRef.current?.sessionId; + if (currentSessionId && autoRejoinSessionIdRef.current === currentSessionId) { + console.log("[AutoRejoin] Already handling auto-rejoin for session:", currentSessionId); + return true; + } + + if (consecutiveAutoRejoinAttemptsRef.current >= 3) { + console.warn("[AutoRejoin] Max consecutive auto-rejoin attempts reached (3), stopping to prevent infinite loop."); + consecutiveAutoRejoinAttemptsRef.current = 0; + return false; + } + + autoRejoinSessionIdRef.current = currentSessionId ?? null; + consecutiveAutoRejoinAttemptsRef.current += 1; + + if (!game) { + console.error("[AutoRejoin] Skipping: streamingGameRef is null (no active game)"); + autoRejoinSessionIdRef.current = null; + return false; + } + if (!playGame) { + console.error("[AutoRejoin] Skipping: handlePlayGameRef is null (ref not synced yet)"); + autoRejoinSessionIdRef.current = null; + return false; + } + + const activeVariantId = sessionRef.current?.appId ?? (game ? variantByGameId[game.id] : undefined); + + const launch = (streamingBaseUrl?: string): void => { + console.log("[AutoRejoin] ✅ Launching rejoin to:", streamingBaseUrl ?? "default region"); + prePingSmartUrlRef.current = null; + prePingInFlightRef.current = false; + prePingPromiseRef.current = null; + resetLaunchRuntime(); + void refreshNavbarActiveSession(); + launchInFlightRef.current = false; + window.setTimeout(() => { + void playGame(game, { bypassGuards: true, streamingBaseUrl, variantId: activeVariantId }).catch((error) => { + console.error("[AutoRejoin] Rejoin launch failed:", error); + }); + }, 500); + }; + + const activeProvider = authSession?.provider ?? selectedProvider; + const isNvidiaAccount = isNvidiaProvider(activeProvider); + const isAllianceServer = isAllianceStreamingBaseUrl(effectiveStreamingBaseUrl); + + if (!isNvidiaAccount || isAllianceServer) { + console.log("[AutoRejoin] Non-NVIDIA or Alliance provider detected — bypassing PrintedWaste server picker and rejoining via provider routing"); + launch(undefined); + return true; + } + + if (cachedUrl) { + console.log("[AutoRejoin] Using pre-fetched URL:", cachedUrl); + launch(cachedUrl); + } else if (prePingInFlightRef.current && prePingPromiseRef.current) { + console.log("[AutoRejoin] Pre-fetch is still in flight — awaiting in-flight result..."); + void prePingPromiseRef.current.then((inFlightUrl) => { + launch(inFlightUrl ?? undefined); + }); + } else { + console.warn("[AutoRejoin] No cached or in-flight URL available — attempting live server pick fallback..."); + void (async () => { + try { + const [queueData, serverMapping] = await Promise.all([ + window.openNow.fetchPrintedWasteQueue(), + window.openNow.fetchPrintedWasteServerMapping().catch(() => null), + ]); + const candidates = Object.entries(queueData) + .filter(([zoneId, zone]) => isStandardPrintedWasteZone(zoneId) && isPrintedWasteZoneFresh(zone) && serverMapping?.[zoneId]?.nuked !== true) + .map(([zoneId]) => ({ zoneId, routingUrl: constructPrintedWasteZoneUrl(zoneId) })); + if (candidates.length > 0) { + const regionsToTest = candidates.map((c) => ({ name: c.zoneId, url: c.routingUrl })); + const pingResults = await window.openNow.pingRegions(regionsToTest).catch(() => []); + const pingMap = new Map(pingResults.map((r) => [r.url, r.pingMs])); + const best = pickBestPrintedWasteZone(queueData, serverMapping, pingMap); + if (best) { + launch(best.routingUrl); + return; + } + } + } catch (err) { + console.warn("[AutoRejoin] Live server pick fallback failed:", err); + } + // Fallback: launch using default region routing + launch(undefined); + })(); + } + return true; + }, [authSession, effectiveStreamingBaseUrl, refreshNavbarActiveSession, resetLaunchRuntime, selectedProvider, settings.enableFastQueueJoin, variantByGameId]); + const handleExpectedNativeSessionClose = useCallback((reason: string): void => { console.log("[Recovery] Treating signaling close as ended session:", reason); const activeGameId = streamingGameRef.current?.id; @@ -2966,9 +3164,12 @@ export function App(): JSX.Element { clientRef.current?.dispose(); clientRef.current = null; launchInFlightRef.current = false; - resetLaunchRuntime(); - void refreshNavbarActiveSession(); - }, [endPlaytimeSession, markExplicitSignalingShutdown, refreshNavbarActiveSession, resetLaunchRuntime]); + const rejoining = startAutoRejoin(reason); + if (!rejoining) { + resetLaunchRuntime(); + void refreshNavbarActiveSession(); + } + }, [endPlaytimeSession, markExplicitSignalingShutdown, refreshNavbarActiveSession, resetLaunchRuntime, startAutoRejoin]); // Signaling events useEffect(() => { @@ -3079,6 +3280,9 @@ export function App(): JSX.Element { setStreamStatus("streaming"); markDiscordStreamStarted(); scheduleStableRecoveryReset(activeSession.sessionId); + // Session confirmed stable — reset auto-rejoin counter so it doesn't + // block future rejoins after successful sessions. + consecutiveAutoRejoinAttemptsRef.current = 0; }; const unsubscribe = window.openNow.onSignalingEvent(async (event: MainToRendererSignalingEvent) => { @@ -3148,6 +3352,9 @@ export function App(): JSX.Element { setStreamStatus("streaming"); markDiscordStreamStarted(); scheduleStableRecoveryReset(activeSession.sessionId); + // Session confirmed stable — reset auto-rejoin counter so it doesn't + // block future rejoins after successful sessions. + consecutiveAutoRejoinAttemptsRef.current = 0; console.log( "[Stream] Offer applied; use [WebRTC] logs for ICE/video dimensions. signalingServer=%s media=%s", activeSession.signalingServer, @@ -3356,10 +3563,13 @@ export function App(): JSX.Element { }); return () => unsubscribe(); - }, [attemptSessionRecovery, diagnosticsStore, handleExpectedNativeSessionClose, markDiscordStreamStarted, nativeInputBridgeReady, refreshNavbarActiveSession, resetLaunchRuntime, scheduleStableRecoveryReset, settings, streamMicLevel, streamVolume, t]); + }, [attemptSessionRecovery, diagnosticsStore, handleExpectedNativeSessionClose, markDiscordStreamStarted, nativeInputBridgeReady, refreshNavbarActiveSession, resetLaunchRuntime, scheduleStableRecoveryReset, settings, startAutoRejoin, streamMicLevel, streamVolume, t]); // Play game handler const handlePlayGame = useCallback(async (game: GameInfo, options?: { bypassGuards?: boolean; streamingBaseUrl?: string; variantId?: string }) => { + if (!options?.bypassGuards) { + consecutiveAutoRejoinAttemptsRef.current = 0; + } if (!selectedProvider) return; console.log("handlePlayGame entry", { @@ -3679,6 +3889,10 @@ export function App(): JSX.Element { warmNativeStreamerForLaunch, ]); + useEffect(() => { + handlePlayGameRef.current = handlePlayGame; + }, [handlePlayGame]); + useEffect(() => { const request = pendingDirectLaunchRequest; if (!request || handledDirectLaunchIdsRef.current.has(request.id)) return; @@ -4178,6 +4392,7 @@ export function App(): JSX.Element { } }, [endPlaytimeSession, markExplicitSignalingShutdown, refreshNavbarActiveSession, resetLaunchRuntime, resolveExitPrompt, stopSessionByTarget, streamingGame]); + const handleDismissLaunchError = useCallback(async () => { markExplicitSignalingShutdown(); await disconnectSignalingControlled(); diff --git a/opennow-stable/src/renderer/src/components/QueueServerSelectModal.tsx b/opennow-stable/src/renderer/src/components/QueueServerSelectModal.tsx index f21a47fd1..968f8486d 100644 --- a/opennow-stable/src/renderer/src/components/QueueServerSelectModal.tsx +++ b/opennow-stable/src/renderer/src/components/QueueServerSelectModal.tsx @@ -5,27 +5,15 @@ import { loadStoredPrintedWastePingResults, saveStoredPrintedWastePingResults, } from "../utils/pingResultsStorage"; +import { + isStandardPrintedWasteZone, + constructPrintedWasteZoneUrl, + pickBestPrintedWasteZone, +} from "../lib/printedWaste"; // ── Constants / helpers ─────────────────────────────────────────────────────── -/** - * Only include standard NVIDIA zones (NP-*). - * Alliance-partner zones start with NPA- and have their own routing - * infrastructure that doesn't follow the cloudmatchbeta.nvidiagrid.net pattern. - */ -function isStandardZone(zoneId: string): boolean { - return zoneId.startsWith("NP-") && !zoneId.startsWith("NPA-"); -} -/** - * Build the direct cloudmatch URL from a zone ID. - * "NP-AMS-08" → "https://np-ams-08.cloudmatchbeta.nvidiagrid.net/" - * This URL is used as streamingBaseUrl in createSession to route the user - * to that specific zone's load balancer. - */ -function constructZoneUrl(zoneId: string): string { - return `https://${zoneId.toLowerCase()}.cloudmatchbeta.nvidiagrid.net/`; -} function formatWait(etaMs: number): string { const mins = Math.ceil(etaMs / 60000); @@ -157,7 +145,7 @@ export function QueueServerSelectModal({ game, initialQueueData = null, onConfir if (cancelled) return; const nextNuked = new Set(); for (const [zoneId, meta] of Object.entries(mapping)) { - if (isStandardZone(zoneId) && meta.nuked === true) { + if (isStandardPrintedWasteZone(zoneId) && meta.nuked === true) { nextNuked.add(zoneId); } } @@ -183,12 +171,12 @@ export function QueueServerSelectModal({ game, initialQueueData = null, onConfir return; } const allStandardZones = Object.entries(queueData) - .filter(([zoneId]) => isStandardZone(zoneId) && !nukedZoneIds.has(zoneId)) + .filter(([zoneId]) => isStandardPrintedWasteZone(zoneId) && !nukedZoneIds.has(zoneId)) .map(([zoneId, zone]) => ({ zoneId, pwRegion: zone.Region, queuePosition: zone.QueuePosition, - routingUrl: constructZoneUrl(zoneId), + routingUrl: constructPrintedWasteZoneUrl(zoneId), })); if (allStandardZones.length === 0) { setZonePings(new Map()); @@ -256,9 +244,9 @@ export function QueueServerSelectModal({ game, initialQueueData = null, onConfir const zones = useMemo(() => { if (!queueData) return []; return Object.entries(queueData) - .filter(([zoneId]) => isStandardZone(zoneId) && !nukedZoneIds?.has(zoneId)) + .filter(([zoneId]) => isStandardPrintedWasteZone(zoneId) && !nukedZoneIds?.has(zoneId)) .map(([zoneId, zone]: [string, PrintedWasteZone]) => { - const routingUrl = constructZoneUrl(zoneId); + const routingUrl = constructPrintedWasteZoneUrl(zoneId); const pingMs = zonePings?.get(routingUrl) ?? null; return { zoneId, @@ -286,20 +274,17 @@ export function QueueServerSelectModal({ game, initialQueueData = null, onConfir // Falls back to queue-only // when ping data isn't in yet. const autoZone = useMemo(() => { - if (zones.length === 0) return null; - const withPing = zones.filter((z) => z.pingMs !== null); - const pool = withPing.length > 0 ? withPing : zones; - const maxPing = Math.max(...pool.map((z) => z.pingMs ?? 999), 1); - const maxQueue = Math.max(...pool.map((z) => z.queuePosition), 1); - return pool.reduce((best, z) => { - const score = ((z.pingMs ?? maxPing) / maxPing) * AUTO_PING_WEIGHT + (z.queuePosition / maxQueue) * AUTO_QUEUE_WEIGHT; - const bScore = ((best.pingMs ?? maxPing) / maxPing) * AUTO_PING_WEIGHT + (best.queuePosition / maxQueue) * AUTO_QUEUE_WEIGHT; - if (score === bScore && z.pingMs !== null && best.pingMs !== null) { - return z.pingMs < best.pingMs ? z : best; - } - return score < bScore ? z : best; - }, pool[0]!); - }, [zones]); + if (!queueData) return null; + const mapping = nukedZoneIds ? Object.fromEntries(Array.from(nukedZoneIds).map((id) => [id, { nuked: true }])) : null; + const pingMap = new Map(); + for (const z of zones) { + pingMap.set(z.routingUrl, z.pingMs); + } + const best = pickBestPrintedWasteZone(queueData, mapping, pingMap); + if (!best) return null; + const zone = zones.find((z) => z.zoneId === best.zoneId); + return zone ?? null; + }, [queueData, nukedZoneIds, zones]); // Closest: lowest latency. Only available after pings complete. const closestZone = useMemo(() => { diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index e4c1e8082..6bdde8385 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -2821,6 +2821,22 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, )} +
+ + +
diff --git a/opennow-stable/src/renderer/src/lib/printedWaste.ts b/opennow-stable/src/renderer/src/lib/printedWaste.ts index d90eb1b52..6e154a72c 100644 --- a/opennow-stable/src/renderer/src/lib/printedWaste.ts +++ b/opennow-stable/src/renderer/src/lib/printedWaste.ts @@ -14,6 +14,68 @@ export function isAllianceStreamingBaseUrl(streamingBaseUrl: string): boolean { } } +export function constructPrintedWasteZoneUrl(zoneId: string): string { + return `https://${zoneId.toLowerCase()}.cloudmatchbeta.nvidiagrid.net/`; +} + +export interface EnrichedZoneInfo { + zoneId: string; + queuePosition: number; + routingUrl: string; + pingMs: number | null; +} + +export function isPrintedWasteZoneFresh(zone: { "Last Updated"?: number }, maxAgeSeconds = 300): boolean { + if (!zone || typeof zone["Last Updated"] !== "number" || isNaN(zone["Last Updated"])) return false; + const now = Math.floor(Date.now() / 1000); + const ts = zone["Last Updated"] > 1e11 ? Math.floor(zone["Last Updated"] / 1000) : zone["Last Updated"]; + const diff = now - ts; + return diff >= -60 && diff <= maxAgeSeconds; +} + +export function pickBestPrintedWasteZone( + queueData: PrintedWasteQueueData, + serverMapping: PrintedWasteServerMapping | null, + pingMap: Map, +): EnrichedZoneInfo | null { + const nukedIds = new Set(); + if (serverMapping) { + for (const [zoneId, meta] of Object.entries(serverMapping)) { + if (meta.nuked) nukedIds.add(zoneId); + } + } + + const candidates = Object.entries(queueData) + .filter(([zoneId, zone]) => isStandardPrintedWasteZone(zoneId) && isPrintedWasteZoneFresh(zone) && !nukedIds.has(zoneId)) + .map(([zoneId, zone]) => ({ + zoneId, + queuePosition: zone.QueuePosition, + routingUrl: constructPrintedWasteZoneUrl(zoneId), + })); + + if (candidates.length === 0) return null; + + const withPing = candidates.map((c) => ({ + ...c, + pingMs: pingMap.get(c.routingUrl) ?? null, + })); + + const pool = withPing.filter((z) => z.pingMs !== null).length > 0 + ? withPing.filter((z) => z.pingMs !== null) + : withPing; + + const maxPing = Math.max(...pool.map((z) => z.pingMs ?? 999), 1); + const maxQueue = Math.max(...pool.map((z) => z.queuePosition), 1); + const AUTO_PING_WEIGHT = 0.75; + const AUTO_QUEUE_WEIGHT = 0.25; + + return pool.reduce((prev, curr) => { + const prevScore = ((prev.pingMs ?? maxPing) / maxPing) * AUTO_PING_WEIGHT + (prev.queuePosition / maxQueue) * AUTO_QUEUE_WEIGHT; + const currScore = ((curr.pingMs ?? maxPing) / maxPing) * AUTO_PING_WEIGHT + (curr.queuePosition / maxQueue) * AUTO_QUEUE_WEIGHT; + return currScore < prevScore ? curr : prev; + }); +} + export function hasAnyEligiblePrintedWasteZone( queueData: PrintedWasteQueueData, mapping: PrintedWasteServerMapping, @@ -22,3 +84,4 @@ export function hasAnyEligiblePrintedWasteZone( isStandardPrintedWasteZone(zoneId) && mapping[zoneId]?.nuked !== true )); } + diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 5bacac1f9..9ae44ac55 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -406,6 +406,8 @@ export interface Settings { autoCheckForUpdates: boolean; /** When true, pressing Escape will exit fullscreen; when false Escape is sent to the game while pointer-locked */ allowEscapeToExitFullscreen?: boolean; + /** Automatically select the GFN region with the shortest queue. */ + enableFastQueueJoin: boolean; /** Last version for which the release highlights modal was acknowledged (empty = never) */ lastSeenReleaseHighlightsVersion: string; /** Client-side GPU post-processing shaders applied to the stream (web client mode) */ @@ -1067,6 +1069,8 @@ export interface StreamSettings { nativeTransitionDiagnostics?: NativeTransitionDiagnostics; /** Requested session app launch mode; "gamepadFriendly" asks NVIDIA to launch games big-picture style. */ appLaunchMode?: AppLaunchMode; + /** Automatically select the GFN region with the shortest queue. */ + enableFastQueueJoin?: boolean; } export interface SessionCreateRequest { @@ -1673,7 +1677,6 @@ export interface OpenNowApi { regenMediaThumbnail(input: { filePath: string }): Promise<{ ok: boolean; thumbnailDataUrl: string | null }>; deleteCache(): Promise; - /** Fetch current GFN queue wait times from the PrintedWaste API */ fetchPrintedWasteQueue(): Promise; /** Fetch PrintedWaste server mapping metadata (includes nuked status) */