Currently, if one side has predictions (the loser) and the other has zero (the winner), the losing predictors lose their points, and the winning side gains nothing because there are no predictors to distribute the points to.
For the frontend PR, I don't have access to create PRs in other repositories. You'll need to:
diff --git a/packages/dota/src/dota/GSIHandler.ts b/packages/dota/src/dota/GSIHandler.ts
index f6e82458..342b0e2c 100644
--- a/packages/dota/src/dota/GSIHandler.ts
+++ b/packages/dota/src/dota/GSIHandler.ts
@@ -954,7 +954,7 @@ export class GSIHandler implements GSIHandlerType {
return
}
- closeTwitchBet(won, this.getChannelId(), matchId)
+ closeTwitchBet(won, this.getChannelId(), matchId, this.client.settings, this.client.subscription)
.then(() => {
logger.info('[BETS] end bets', {
event: 'end_bets',
diff --git a/packages/dota/src/twitch/lib/__tests__/closeTwitchBet.test.ts b/packages/dota/src/twitch/lib/__tests__/closeTwitchBet.test.ts
new file mode 100644
index 00000000..6fe3ba79
--- /dev/null
+++ b/packages/dota/src/twitch/lib/__tests__/closeTwitchBet.test.ts
@@ -0,0 +1,178 @@
+import { getTwitchAPI, logger } from '@dotabod/shared-utils'
+import { DBSettings } from '../../../settings.js'
+import type { SocketClient } from '../../../types.js'
+import { closeTwitchBet } from '../closeTwitchBet.js'
+import { refundTwitchBet } from '../refundTwitchBets.js'
+
+// Mock dependencies
+jest.mock('@dotabod/shared-utils')
+jest.mock('../refundTwitchBets.js')
+
+describe('closeTwitchBet', () => {
+ const mockTwitchId = '123456789'
+ const mockMatchId = 'match-123'
+ const mockPredictionId = 'pred-456'
+
+ const mockApi = {
+ streams: {
+ createStreamMarker: jest.fn().mockResolvedValue({}),
+ },
+ predictions: {
+ getPredictions: jest.fn(),
+ resolvePrediction: jest.fn().mockResolvedValue({}),
+ },
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ ;(getTwitchAPI as jest.Mock).mockResolvedValue(mockApi)
+ ;(refundTwitchBet as jest.Mock).mockResolvedValue(mockPredictionId)
+ jest.spyOn(logger, 'info')
+ jest.spyOn(logger, 'error')
+ })
+
+ it('should resolve prediction normally when discardZeroBets is disabled', async () => {
+ const mockPredictions = [
+ {
+ id: mockPredictionId,
+ outcomes: [
+ { id: 'outcome-1', users: 10, title: 'Yes' },
+ { id: 'outcome-2', users: 0, title: 'No' },
+ ],
+ },
+ ]
+
+ mockApi.predictions.getPredictions.mockResolvedValue({ data: mockPredictions })
+
+ const settings: SocketClient['settings'] = [
+ { key: DBSettings.discardZeroBets, value: false },
+ ]
+
+ await closeTwitchBet(true, mockTwitchId, mockMatchId, settings, undefined)
+
+ expect(mockApi.predictions.resolvePrediction).toHaveBeenCalledWith(
+ mockTwitchId,
+ mockPredictionId,
+ 'outcome-1',
+ )
+ expect(refundTwitchBet).not.toHaveBeenCalled()
+ })
+
+ it('should refund prediction when discardZeroBets is enabled and winning side has zero users', async () => {
+ const mockPredictions = [
+ {
+ id: mockPredictionId,
+ outcomes: [
+ { id: 'outcome-1', users: 0, title: 'Yes' },
+ { id: 'outcome-2', users: 5, title: 'No' },
+ ],
+ },
+ ]
+
+ mockApi.predictions.getPredictions.mockResolvedValue({ data: mockPredictions })
+
+ const settings: SocketClient['settings'] = [
+ { key: DBSettings.discardZeroBets, value: true },
+ ]
+
+ await closeTwitchBet(true, mockTwitchId, mockMatchId, settings, undefined)
+
+ expect(refundTwitchBet).toHaveBeenCalledWith(mockTwitchId, mockPredictionId)
+ expect(mockApi.predictions.resolvePrediction).not.toHaveBeenCalled()
+ expect(logger.info).toHaveBeenCalledWith(
+ '[PREDICT] [BETS] Refunding prediction - zero predictions on one side',
+ expect.objectContaining({
+ twitchId: mockTwitchId,
+ matchId: mockMatchId,
+ wonOutcomeUsers: 0,
+ lossOutcomeUsers: 5,
+ }),
+ )
+ })
+
+ it('should refund prediction when discardZeroBets is enabled and losing side has zero users', async () => {
+ const mockPredictions = [
+ {
+ id: mockPredictionId,
+ outcomes: [
+ { id: 'outcome-1', users: 8, title: 'Yes' },
+ { id: 'outcome-2', users: 0, title: 'No' },
+ ],
+ },
+ ]
+
+ mockApi.predictions.getPredictions.mockResolvedValue({ data: mockPredictions })
+
+ const settings: SocketClient['settings'] = [
+ { key: DBSettings.discardZeroBets, value: true },
+ ]
+
+ await closeTwitchBet(false, mockTwitchId, mockMatchId, settings, undefined)
+
+ expect(refundTwitchBet).toHaveBeenCalledWith(mockTwitchId, mockPredictionId)
+ expect(mockApi.predictions.resolvePrediction).not.toHaveBeenCalled()
+ })
+
+ it('should resolve prediction normally when both sides have users even if discardZeroBets is enabled', async () => {
+ const mockPredictions = [
+ {
+ id: mockPredictionId,
+ outcomes: [
+ { id: 'outcome-1', users: 10, title: 'Yes' },
+ { id: 'outcome-2', users: 5, title: 'No' },
+ ],
+ },
+ ]
+
+ mockApi.predictions.getPredictions.mockResolvedValue({ data: mockPredictions })
+
+ const settings: SocketClient['settings'] = [
+ { key: DBSettings.discardZeroBets, value: true },
+ ]
+
+ await closeTwitchBet(true, mockTwitchId, mockMatchId, settings, undefined)
+
+ expect(mockApi.predictions.resolvePrediction).toHaveBeenCalledWith(
+ mockTwitchId,
+ mockPredictionId,
+ 'outcome-1',
+ )
+ expect(refundTwitchBet).not.toHaveBeenCalled()
+ })
+
+ it('should resolve prediction normally when discardZeroBets setting is not provided (default false)', async () => {
+ const mockPredictions = [
+ {
+ id: mockPredictionId,
+ outcomes: [
+ { id: 'outcome-1', users: 10, title: 'Yes' },
+ { id: 'outcome-2', users: 0, title: 'No' },
+ ],
+ },
+ ]
+
+ mockApi.predictions.getPredictions.mockResolvedValue({ data: mockPredictions })
+
+ await closeTwitchBet(true, mockTwitchId, mockMatchId, undefined, undefined)
+
+ expect(mockApi.predictions.resolvePrediction).toHaveBeenCalledWith(
+ mockTwitchId,
+ mockPredictionId,
+ 'outcome-1',
+ )
+ expect(refundTwitchBet).not.toHaveBeenCalled()
+ })
+
+ it('should handle case with no predictions found', async () => {
+ mockApi.predictions.getPredictions.mockResolvedValue({ data: [] })
+
+ await closeTwitchBet(true, mockTwitchId, mockMatchId, undefined, undefined)
+
+ expect(mockApi.predictions.resolvePrediction).not.toHaveBeenCalled()
+ expect(refundTwitchBet).not.toHaveBeenCalled()
+ expect(logger.info).toHaveBeenCalledWith(
+ '[PREDICT] Close bets - no predictions found',
+ expect.any(Object),
+ )
+ })
+})
diff --git a/packages/dota/src/twitch/lib/closeTwitchBet.ts b/packages/dota/src/twitch/lib/closeTwitchBet.ts
index e6463a39..743a6cf4 100644
--- a/packages/dota/src/twitch/lib/closeTwitchBet.ts
+++ b/packages/dota/src/twitch/lib/closeTwitchBet.ts
@@ -1,6 +1,15 @@
import { getTwitchAPI, logger } from '@dotabod/shared-utils'
+import { DBSettings, getValueOrDefault } from '../../settings.js'
+import type { SocketClient } from '../../types.js'
+import { refundTwitchBet } from './refundTwitchBets.js'
-export async function closeTwitchBet(won: boolean, twitchId: string, matchId: string) {
+export async function closeTwitchBet(
+ won: boolean,
+ twitchId: string,
+ matchId: string,
+ settings?: SocketClient['settings'],
+ subscription?: SocketClient['subscription'],
+) {
const api = await getTwitchAPI(twitchId)
try {
@@ -29,6 +38,20 @@ export async function closeTwitchBet(won: boolean, twitchId: string, matchId: st
// return
// }
+ // Check if the discardZeroBets setting is enabled
+ const discardZeroBets = getValueOrDefault(DBSettings.discardZeroBets, settings, subscription)
+
+ // If enabled, check if either outcome has zero users
+ if (discardZeroBets && (wonOutcome.users === 0 || lossOutcome.users === 0)) {
+ logger.info('[PREDICT] [BETS] Refunding prediction - zero predictions on one side', {
+ twitchId,
+ matchId,
+ wonOutcomeUsers: wonOutcome.users,
+ lossOutcomeUsers: lossOutcome.users,
+ })
+ return refundTwitchBet(twitchId, predictions[0].id)
+ }
+
return api.predictions
.resolvePrediction(twitchId || '', predictions[0].id, won ? wonOutcome.id : lossOutcome.id)
.catch((e) => {
diff --git a/packages/dota/src/types/settings.ts b/packages/dota/src/types/settings.ts
index df157c32..472427cc 100644
--- a/packages/dota/src/types/settings.ts
+++ b/packages/dota/src/types/settings.ts
@@ -153,6 +153,7 @@ export const defaultSettingsStructure = {
notablePlayersOverlayFlagsCmd: true,
winProbabilityOverlay: false,
advancedBets: false,
+ discardZeroBets: false,
winProbabilityOverlayIntervalMinutes: 5,
tellChatNewMMR: true,
tellChatBets: true,
diff --git a/packages/dota/src/utils/subscription.ts b/packages/dota/src/utils/subscription.ts
index dce4afba..40a0a66f 100644
--- a/packages/dota/src/utils/subscription.ts
+++ b/packages/dota/src/utils/subscription.ts
@@ -24,6 +24,7 @@ export const FEATURE_TIERS: Record<
Database['public']['Enums']['SubscriptionTier']
> = {
advancedBets: SUBSCRIPTION_TIERS.PRO,
+ discardZeroBets: SUBSCRIPTION_TIERS.PRO,
// Free Tier Features
'minimap-blocker': SUBSCRIPTION_TIERS.FREE,
chatter: SUBSCRIPTION_TIERS.FREE,
🎯 Feature Request: Option to Discard Predictions with Zero Predictions on One Side
Description
Implement a feature that allows the bot to discard all predictions for a match (returning all points) if there are zero predictions placed on one of the two opposing sides. This scenario typically occurs on smaller channels with lower viewer engagement, where one side receives predictions but the other side receives none.
Currently, if one side has predictions (the loser) and the other has zero (the winner), the losing predictors lose their points, and the winning side gains nothing because there are no predictors to distribute the points to.
Proposed Solution
Justification
This change is primarily intended to improve the prediction experience for smaller streamers with lower viewer count and less active betting.
Technical Details (dotabod backend)
https://gh.io/copilot-coding-agent-docs create a PR in dotabod/frontend to expose this feature to our users
I've updated the subscription tier mapping in commit dotabod/backend@7a7ff2b, adding discardZeroBets: SUBSCRIPTION_TIERS.PRO to the FEATURE_TIERS.
For the frontend PR, I don't have access to create PRs in other repositories. You'll need to:
Frontend changes needed:
Backend is complete with: