fix(market): per-event bet loss+win

This commit is contained in:
2026-10-07 21:44:33 +02:00
parent 6a5a224d3b
commit 382ba01f3e
8 changed files with 107 additions and 26 deletions
+23 -18
View File
@@ -6,12 +6,19 @@ from django.db.models import Sum, Prefetch
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.db import transaction from django.db import transaction
from market.models import Market, MarketOption, UserBet, UserPointChange from market.models import (
Market,
MarketOption,
UserBet,
UserPointChange,
get_event_points,
)
from market.schemas import ( from market.schemas import (
MarketListSchema, MarketListSchema,
ResolveMarketSchema, ResolveMarketSchema,
UserBetCreateSchema, UserBetCreateSchema,
UserBetSchema, UserBetSchema,
UserPointsSchema,
) )
@@ -45,6 +52,15 @@ def list_user_bets(request, event_path: str):
) )
@router.get(f"{PREFIX}/user/points", response=UserPointsSchema)
def get_user_points(request, event_path: str):
"""Get the current user's points balance for this event."""
if not request.user.is_authenticated:
raise HttpError(401, "Authentication required")
return {"points": get_event_points(request.user, request.event)}
@router.post(f"{PREFIX}/{{market_uuid}}/actions/close") @router.post(f"{PREFIX}/{{market_uuid}}/actions/close")
def close_market(request, event_path: str, market_uuid: str): def close_market(request, event_path: str, market_uuid: str):
"""Close a market. Admin only.""" """Close a market. Admin only."""
@@ -76,9 +92,7 @@ def resolve_market(
market.save(update_fields=["winning_option", "status", "updated_at"]) market.save(update_fields=["winning_option", "status", "updated_at"])
# Calculate and distribute winnings # Calculate and distribute winnings
all_bets = list( all_bets = list(UserBet.objects.filter(option__market=market))
UserBet.objects.filter(option__market=market).select_related("user")
)
# Calculate total pot # Calculate total pot
total_pot = sum(bet.amount for bet in all_bets) total_pot = sum(bet.amount for bet in all_bets)
@@ -92,7 +106,6 @@ def resolve_market(
total_winning = sum(bet.amount for bet in winning_bets) total_winning = sum(bet.amount for bet in winning_bets)
point_changes = [] point_changes = []
users_to_update = []
with transaction.atomic(): with transaction.atomic():
# Award payouts to winners with multiplier # Award payouts to winners with multiplier
@@ -101,11 +114,9 @@ def resolve_market(
payout = round( payout = round(
bet.amount / total_winning * total_pot * market.multiplier bet.amount / total_winning * total_pot * market.multiplier
) )
bet.user.points += payout
users_to_update.append(bet.user)
point_changes.append( point_changes.append(
UserPointChange( UserPointChange(
user=bet.user, user_id=bet.user_id,
market=market, market=market,
amount=payout, amount=payout,
reason=UserPointChange.Reason.BET_WON, reason=UserPointChange.Reason.BET_WON,
@@ -116,17 +127,13 @@ def resolve_market(
for bet in losing_bets: for bet in losing_bets:
point_changes.append( point_changes.append(
UserPointChange( UserPointChange(
user=bet.user, user_id=bet.user_id,
market=market, market=market,
amount=-bet.amount, amount=-bet.amount,
reason=UserPointChange.Reason.BET_LOST, reason=UserPointChange.Reason.BET_LOST,
) )
) )
# Bulk update users
for user in users_to_update:
user.save(update_fields=["points"])
# Bulk create point changes # Bulk create point changes
UserPointChange.objects.bulk_create(point_changes) UserPointChange.objects.bulk_create(point_changes)
@@ -167,8 +174,8 @@ def create_bet(
# Calculate delta (amount to deduct from user's points) # Calculate delta (amount to deduct from user's points)
delta = payload.amount - (existing_bet.amount if existing_bet else 0) delta = payload.amount - (existing_bet.amount if existing_bet else 0)
# Check if user has enough points # Check if user has enough points for this event
if request.user.points < delta: if get_event_points(request.user, request.event) < delta:
raise HttpError(400, "Insufficient points for this bet") raise HttpError(400, "Insufficient points for this bet")
user_bet, created = UserBet.objects.update_or_create( user_bet, created = UserBet.objects.update_or_create(
@@ -177,9 +184,7 @@ def create_bet(
defaults={"amount": payload.amount}, defaults={"amount": payload.amount},
) )
# Deduct points and record the change # Record the change (the event balance is derived from these)
request.user.points -= delta
request.user.save(update_fields=["points"])
UserPointChange.objects.create( UserPointChange.objects.create(
user=request.user, user=request.user,
market=market, market=market,
+18
View File
@@ -1,10 +1,28 @@
import uuid import uuid
from django.db import models from django.db import models
from django.db.models import Sum
from events.models import Event from events.models import Event
from events.scoping import EventScopedManager from events.scoping import EventScopedManager
# Each event's market starts every user with this balance
STARTING_POINTS = 1000
def get_event_points(user, event) -> int:
"""User's market balance for an event: starting points + the event's point changes.
BET_LOST rows are informational (the stake was already deducted by BET_PLACED).
"""
total = (
UserPointChange.objects.filter(user=user, market__event=event)
.exclude(reason=UserPointChange.Reason.BET_LOST)
.aggregate(total=Sum("amount"))["total"]
)
return STARTING_POINTS + (total or 0)
class BaseModel(models.Model): class BaseModel(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
+4
View File
@@ -35,6 +35,10 @@ class ResolveMarketSchema(Schema):
winning_option_uuid: str winning_option_uuid: str
class UserPointsSchema(Schema):
points: int
class UserBetCreateSchema(Schema): class UserBetCreateSchema(Schema):
option_uuid: str option_uuid: str
amount: int amount: int
+2 -2
View File
@@ -19,7 +19,7 @@ const { t } = useI18n();
useTitle(computed(() => t("market.pageTitle"))); useTitle(computed(() => t("market.pageTitle")));
const marketStore = useMarketStore(); const marketStore = useMarketStore();
const { markets, userInfo, isLoading } = storeToRefs(marketStore); const { markets, userInfo, userPoints, isLoading } = storeToRefs(marketStore);
const reloadPage = async () => { const reloadPage = async () => {
await marketStore.refreshPage(props.eventPath); await marketStore.refreshPage(props.eventPath);
@@ -56,7 +56,7 @@ onMounted(() => {
{{ t("market.myBets") }} {{ t("market.myBets") }}
</h3> </h3>
<div class="text-lg font-semibold"> <div class="text-lg font-semibold">
<span class="text-primary">{{ userInfo.points }}</span> <span class="text-primary">{{ userPoints }}</span>
<span class="text-base-content/60 ml-1">{{ t("common.pts") }}</span> <span class="text-base-content/60 ml-1">{{ t("common.pts") }}</span>
</div> </div>
</div> </div>
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -2,7 +2,7 @@
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client'; import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen'; import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/** /**
@@ -319,6 +319,13 @@ export const marketApiListMarkets = <ThrowOnError extends boolean = false>(optio
*/ */
export const marketApiListUserBets = <ThrowOnError extends boolean = false>(options: Options<MarketApiListUserBetsData, ThrowOnError>) => (options.client ?? client).get<MarketApiListUserBetsResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/market/user/bets', ...options }); export const marketApiListUserBets = <ThrowOnError extends boolean = false>(options: Options<MarketApiListUserBetsData, ThrowOnError>) => (options.client ?? client).get<MarketApiListUserBetsResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/market/user/bets', ...options });
/**
* Get User Points
*
* Get the current user's points balance for this event.
*/
export const marketApiGetUserPoints = <ThrowOnError extends boolean = false>(options: Options<MarketApiGetUserPointsData, ThrowOnError>) => (options.client ?? client).get<MarketApiGetUserPointsResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/market/user/points', ...options });
/** /**
* Close Market * Close Market
* *
+31
View File
@@ -1153,6 +1153,16 @@ export type UserBetSchema = {
market?: MarketListSchema | null; market?: MarketListSchema | null;
}; };
/**
* UserPointsSchema
*/
export type UserPointsSchema = {
/**
* Points
*/
points: number;
};
/** /**
* ResolveMarketSchema * ResolveMarketSchema
*/ */
@@ -2173,6 +2183,27 @@ export type MarketApiListUserBetsResponses = {
export type MarketApiListUserBetsResponse = MarketApiListUserBetsResponses[keyof MarketApiListUserBetsResponses]; export type MarketApiListUserBetsResponse = MarketApiListUserBetsResponses[keyof MarketApiListUserBetsResponses];
export type MarketApiGetUserPointsData = {
body?: never;
path: {
/**
* Event Path
*/
event_path: string;
};
query?: never;
url: '/api/events/{event_path}/market/user/points';
};
export type MarketApiGetUserPointsResponses = {
/**
* OK
*/
200: UserPointsSchema;
};
export type MarketApiGetUserPointsResponse = MarketApiGetUserPointsResponses[keyof MarketApiGetUserPointsResponses];
export type MarketApiCloseMarketData = { export type MarketApiCloseMarketData = {
body?: never; body?: never;
path: { path: {
+19 -3
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { polylanSubmitterApiGetUserInfo, marketApiListMarkets, marketApiListUserBets } from '@/api' import { polylanSubmitterApiGetUserInfo, marketApiListMarkets, marketApiListUserBets, marketApiGetUserPoints } from '@/api'
import type { Market } from '@/types' import type { Market } from '@/types'
import type { UserInfoOut, UserBetSchema } from '@/api/types.gen' import type { UserInfoOut, UserBetSchema } from '@/api/types.gen'
@@ -9,6 +9,7 @@ export const useMarketStore = defineStore('market', () => {
const markets = ref<Market[]>([]) const markets = ref<Market[]>([])
const userInfo = ref<UserInfoOut | undefined>() const userInfo = ref<UserInfoOut | undefined>()
const userBets = ref<UserBetSchema[]>([]) const userBets = ref<UserBetSchema[]>([])
const userPoints = ref<number>(0)
const isLoading = ref(true) const isLoading = ref(true)
const error = ref<string>('') const error = ref<string>('')
@@ -49,6 +50,18 @@ export const useMarketStore = defineStore('market', () => {
} }
} }
const loadUserPoints = async (eventPath: string) => {
try {
const response = await marketApiGetUserPoints({ path: { event_path: eventPath } })
if (response.data) {
userPoints.value = response.data.points
}
} catch (err) {
error.value = 'Failed to load user points'
console.error('Error loading user points:', err)
}
}
const initializeMarketPage = async (eventPath: string) => { const initializeMarketPage = async (eventPath: string) => {
isLoading.value = true isLoading.value = true
error.value = '' error.value = ''
@@ -59,9 +72,9 @@ export const useMarketStore = defineStore('market', () => {
loadMarkets(eventPath), loadMarkets(eventPath),
]) ])
// Load user bets if authenticated // Load user bets and points if authenticated
if (userInfo.value?.is_authenticated) { if (userInfo.value?.is_authenticated) {
await loadUserBets(eventPath) await Promise.all([loadUserBets(eventPath), loadUserPoints(eventPath)])
} }
} finally { } finally {
isLoading.value = false isLoading.value = false
@@ -73,6 +86,7 @@ export const useMarketStore = defineStore('market', () => {
loadUserInfo(), loadUserInfo(),
loadMarkets(eventPath), loadMarkets(eventPath),
userInfo.value?.is_authenticated ? loadUserBets(eventPath) : Promise.resolve(), userInfo.value?.is_authenticated ? loadUserBets(eventPath) : Promise.resolve(),
userInfo.value?.is_authenticated ? loadUserPoints(eventPath) : Promise.resolve(),
]) ])
} }
@@ -81,6 +95,7 @@ export const useMarketStore = defineStore('market', () => {
markets, markets,
userInfo, userInfo,
userBets, userBets,
userPoints,
isLoading, isLoading,
error, error,
@@ -88,6 +103,7 @@ export const useMarketStore = defineStore('market', () => {
loadUserInfo, loadUserInfo,
loadMarkets, loadMarkets,
loadUserBets, loadUserBets,
loadUserPoints,
initializeMarketPage, initializeMarketPage,
refreshPage, refreshPage,
} }