-
-
Notifications
You must be signed in to change notification settings - Fork 12
Description
🎯 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
- Introduce a toggleable configuration option (e.g., in a stream-specific settings panel or environment variable) for this behavior. It should be disabled by default.
- If the setting is enabled, the bot should check if the total number of predictions for either side is zero before finalizing the prediction results.
- If one side has zero predictions, the prediction should be discarded entirely:
- All points wagered should be returned to the users who made a prediction.
- No points should be awarded or lost.
Justification
This change is primarily intended to improve the prediction experience for smaller streamers with lower viewer count and less active betting.
- Without this change, early predictors on the losing side are "punished" by losing their points in a scenario where the winning side has no one to claim the prize, leading to a negative viewer experience with no potential upside.
- By discarding the predictions and returning points, users keep their points for future predictions, which is argued to be a better outcome for viewer engagement on smaller channels.
Technical Details (dotabod backend)
- Needs a new configuration flag to store the setting state.
- Logic modification in the prediction resolution phase to check prediction counts per side.
- Implementation of the point refund logic if the condition is met and the feature is enabled.
https://gh.io/copilot-coding-agent-docs create a PR in dotabod/frontend to expose this feature to our users
dont forget to update the subscription type for this new setting too in backend repo and frontend rep...
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:
Add discardZeroBets to the subscription feature mapping (similar to backend subscription.ts)
Add a UI toggle/checkbox in the betting settings panel to enable/disable this feature
Ensure the setting is properly saved to the database when changed
Backend is complete with:
Setting added to default settings structure
Subscription tier mapping (PRO tier)
Refund logic implementation
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,